From ddf867d1f19c0a61b8ef8773fb3445565f12b7b1 Mon Sep 17 00:00:00 2001 From: Masaki Yatsu Date: Sun, 31 Aug 2025 16:28:32 +0900 Subject: [PATCH] feat(keycloak): set access/refresh token lifespan --- keycloak/justfile | 22 ++++- keycloak/scripts/create-realm.ts | 25 ++++- keycloak/scripts/show-realm-token-settings.ts | 80 ++++++++++++++++ .../scripts/update-realm-token-settings.ts | 91 +++++++++++++++++++ 4 files changed, 216 insertions(+), 2 deletions(-) create mode 100644 keycloak/scripts/show-realm-token-settings.ts create mode 100644 keycloak/scripts/update-realm-token-settings.ts diff --git a/keycloak/justfile b/keycloak/justfile index 74cfde5..67c10e1 100644 --- a/keycloak/justfile +++ b/keycloak/justfile @@ -112,11 +112,15 @@ uninstall delete-db='true': fi # Create Keycloak realm -create-realm create-client-for-k8s='true': +create-realm create-client-for-k8s='true' access_token_lifespan='3600' refresh_token_lifespan='14400' sso_session_idle_timeout='7200': #!/bin/bash set -euo pipefail export KEYCLOAK_ADMIN_USER=$(just admin-username) export KEYCLOAK_ADMIN_PASSWORD=$(just admin-password) + export ACCESS_TOKEN_LIFESPAN={{ access_token_lifespan }} + export REFRESH_TOKEN_LIFESPAN={{ refresh_token_lifespan }} + export SSO_SESSION_MAX_LIFESPAN={{ refresh_token_lifespan }} + export SSO_SESSION_IDLE_TIMEOUT={{ sso_session_idle_timeout }} dotenvx run -f ../.env.local -- tsx ./scripts/create-realm.ts if [ "{{ create-client-for-k8s }}" = "true" ]; then just create-k8s-client @@ -412,3 +416,19 @@ default-admin-password: @kubectl get secret keycloak-credentials -n ${KEYCLOAK_NAMESPACE} \ -o jsonpath="{.data.password}" | base64 --decode @echo + +# Show current realm token settings +show-realm-token-settings realm: + #!/bin/bash + set -euo pipefail + export KEYCLOAK_REALM={{ realm }} + dotenvx run -f ../.env.local -- tsx ./scripts/show-realm-token-settings.ts + +# Update realm token settings (access token lifespan, refresh token lifespan, etc.) +update-realm-token-settings realm access_token_lifespan='3600' refresh_token_lifespan='1800': + #!/bin/bash + set -euo pipefail + export KEYCLOAK_REALM={{ realm }} + export ACCESS_TOKEN_LIFESPAN={{ access_token_lifespan }} + export REFRESH_TOKEN_LIFESPAN={{ refresh_token_lifespan }} + dotenvx run -f ../.env.local -- tsx ./scripts/update-realm-token-settings.ts diff --git a/keycloak/scripts/create-realm.ts b/keycloak/scripts/create-realm.ts index 358c32e..eb00e38 100644 --- a/keycloak/scripts/create-realm.ts +++ b/keycloak/scripts/create-realm.ts @@ -14,6 +14,12 @@ const main = async () => { const realmName = process.env.KEYCLOAK_REALM; invariant(realmName, "KEYCLOAK_REALM environment variable is required"); + // Token lifespan settings (with defaults suitable for JupyterHub) + const accessTokenLifespan = parseInt(process.env.ACCESS_TOKEN_LIFESPAN || "3600"); // 1 hour + const refreshTokenLifespan = parseInt(process.env.REFRESH_TOKEN_LIFESPAN || "14400"); // 4 hours - changed from 30min + const ssoSessionMaxLifespan = parseInt(process.env.SSO_SESSION_MAX_LIFESPAN || refreshTokenLifespan.toString()); // Use refreshTokenLifespan + const ssoSessionIdleTimeout = parseInt(process.env.SSO_SESSION_IDLE_TIMEOUT || "7200"); // 2 hours + const kcAdminClient = new KcAdminClient({ baseUrl: `https://${keycloakHost}`, realmName: "master", @@ -38,8 +44,25 @@ const main = async () => { await kcAdminClient.realms.create({ realm: realmName, enabled: true, + // Token lifespan settings + accessTokenLifespan: accessTokenLifespan, + accessTokenLifespanForImplicitFlow: accessTokenLifespan, + ssoSessionMaxLifespan: ssoSessionMaxLifespan, + ssoSessionIdleTimeout: Math.min(ssoSessionMaxLifespan, ssoSessionIdleTimeout), + // Refresh token settings + refreshTokenMaxReuse: 0, + // Offline session settings + offlineSessionMaxLifespan: ssoSessionMaxLifespan * 2, + offlineSessionMaxLifespanEnabled: true, + // Client session settings + clientSessionMaxLifespan: accessTokenLifespan, + clientSessionIdleTimeout: Math.min(accessTokenLifespan, ssoSessionIdleTimeout), }); - console.log(`Realm '${realmName}' created successfully.`); + console.log(`Realm '${realmName}' created successfully with token settings:`); + console.log(` - Access Token Lifespan: ${accessTokenLifespan} seconds (${accessTokenLifespan/60} minutes)`); + console.log(` - Refresh Token Lifespan: ${refreshTokenLifespan} seconds (${refreshTokenLifespan/60} minutes)`); + console.log(` - SSO Session Max: ${ssoSessionMaxLifespan} seconds (${ssoSessionMaxLifespan/60} minutes)`); + console.log(` - SSO Session Idle: ${ssoSessionIdleTimeout} seconds (${ssoSessionIdleTimeout/60} minutes)`); } catch (error) { console.error("An error occurred:", error); // eslint-disable-next-line unicorn/no-process-exit diff --git a/keycloak/scripts/show-realm-token-settings.ts b/keycloak/scripts/show-realm-token-settings.ts new file mode 100644 index 0000000..8b2fbbe --- /dev/null +++ b/keycloak/scripts/show-realm-token-settings.ts @@ -0,0 +1,80 @@ +#!/usr/bin/env tsx + +import KcAdminClient from "@keycloak/keycloak-admin-client"; +import invariant from "tiny-invariant"; + +const main = async () => { + // Environment variables + const keycloakHost = process.env.KEYCLOAK_HOST; + const adminUser = process.env.KEYCLOAK_ADMIN_USER; + const adminPassword = process.env.KEYCLOAK_ADMIN_PASSWORD; + const realm = process.env.KEYCLOAK_REALM; + + invariant(keycloakHost, "KEYCLOAK_HOST is required"); + invariant(adminUser, "KEYCLOAK_ADMIN_USER is required"); + invariant(adminPassword, "KEYCLOAK_ADMIN_PASSWORD is required"); + invariant(realm, "KEYCLOAK_REALM is required"); + + console.log(`Checking token settings for realm: ${realm}`); + + // Initialize Keycloak admin client + const kcAdminClient = new KcAdminClient({ + baseUrl: `https://${keycloakHost}`, + realmName: "master", + }); + + try { + // Authenticate + await kcAdminClient.auth({ + username: adminUser, + password: adminPassword, + grantType: "password", + clientId: "admin-cli", + }); + + console.log("✓ Authenticated with Keycloak admin"); + + // Set the target realm + kcAdminClient.setConfig({ realmName: realm }); + + // Get current realm settings + const currentRealm = await kcAdminClient.realms.findOne({ realm }); + if (!currentRealm) { + throw new Error(`Realm ${realm} not found`); + } + + console.log(`\n=== Current Token Settings for Realm: ${realm} ===`); + console.log(`Access Token Lifespan: ${currentRealm.accessTokenLifespan || 'not set'} seconds (${(currentRealm.accessTokenLifespan || 0)/60} minutes)`); + console.log(`Access Token Lifespan (Implicit): ${currentRealm.accessTokenLifespanForImplicitFlow || 'not set'} seconds`); + console.log(`SSO Session Max Lifespan: ${currentRealm.ssoSessionMaxLifespan || 'not set'} seconds (${(currentRealm.ssoSessionMaxLifespan || 0)/60} minutes)`); + console.log(`SSO Session Idle Timeout: ${currentRealm.ssoSessionIdleTimeout || 'not set'} seconds (${(currentRealm.ssoSessionIdleTimeout || 0)/60} minutes)`); + console.log(`Client Session Max Lifespan: ${currentRealm.clientSessionMaxLifespan || 'not set'} seconds`); + console.log(`Client Session Idle Timeout: ${currentRealm.clientSessionIdleTimeout || 'not set'} seconds`); + console.log(`Offline Session Max Lifespan: ${currentRealm.offlineSessionMaxLifespan || 'not set'} seconds`); + console.log(`Refresh Token Max Reuse: ${currentRealm.refreshTokenMaxReuse || 0}`); + + // Also check specific client settings if JupyterHub client exists + try { + const clients = await kcAdminClient.clients.find({ clientId: 'jupyterhub' }); + if (clients.length > 0) { + const jupyterhubClient = clients[0]; + console.log(`\n=== JupyterHub Client Settings ===`); + console.log(`Client ID: ${jupyterhubClient.clientId}`); + console.log(`Access Token Lifespan: ${jupyterhubClient.attributes?.['access.token.lifespan'] || 'inherit from realm'}`); + } + } catch (clientError) { + console.log(`\n⚠️ Could not retrieve JupyterHub client settings: ${clientError}`); + } + + console.log(`\n=== Keycloak Default Values (for reference) ===`); + console.log(`Default Access Token Lifespan: 300 seconds (5 minutes)`); + console.log(`Default SSO Session Max: 36000 seconds (10 hours)`); + console.log(`Default SSO Session Idle: 1800 seconds (30 minutes)`); + + } catch (error) { + console.error("✗ Failed to retrieve realm token settings:", error); + process.exit(1); + } +}; + +main().catch(console.error); \ No newline at end of file diff --git a/keycloak/scripts/update-realm-token-settings.ts b/keycloak/scripts/update-realm-token-settings.ts new file mode 100644 index 0000000..60cc034 --- /dev/null +++ b/keycloak/scripts/update-realm-token-settings.ts @@ -0,0 +1,91 @@ +#!/usr/bin/env tsx + +import KcAdminClient from "@keycloak/keycloak-admin-client"; +import invariant from "tiny-invariant"; + +const main = async () => { + // Environment variables + const keycloakHost = process.env.KEYCLOAK_HOST; + const adminUser = process.env.KEYCLOAK_ADMIN_USER; + const adminPassword = process.env.KEYCLOAK_ADMIN_PASSWORD; + const realm = process.env.KEYCLOAK_REALM; + const accessTokenLifespan = parseInt(process.env.ACCESS_TOKEN_LIFESPAN || "3600"); + const refreshTokenLifespan = parseInt(process.env.REFRESH_TOKEN_LIFESPAN || "1800"); + + invariant(keycloakHost, "KEYCLOAK_HOST is required"); + invariant(adminUser, "KEYCLOAK_ADMIN_USER is required"); + invariant(adminPassword, "KEYCLOAK_ADMIN_PASSWORD is required"); + invariant(realm, "KEYCLOAK_REALM is required"); + + console.log(`Updating token settings for realm: ${realm}`); + console.log(`Access token lifespan: ${accessTokenLifespan} seconds (${accessTokenLifespan/60} minutes)`); + console.log(`Refresh token lifespan: ${refreshTokenLifespan} seconds (${refreshTokenLifespan/60} minutes)`); + + // Initialize Keycloak admin client + const kcAdminClient = new KcAdminClient({ + baseUrl: `https://${keycloakHost}`, + realmName: "master", + }); + + try { + // Authenticate + await kcAdminClient.auth({ + username: adminUser, + password: adminPassword, + grantType: "password", + clientId: "admin-cli", + }); + + console.log("✓ Authenticated with Keycloak admin"); + + // Set the target realm + kcAdminClient.setConfig({ realmName: realm }); + + // Get current realm settings + const currentRealm = await kcAdminClient.realms.findOne({ realm }); + if (!currentRealm) { + throw new Error(`Realm ${realm} not found`); + } + + console.log(`Current settings:`); + console.log(` - Access token lifespan: ${currentRealm.accessTokenLifespan} seconds`); + console.log(` - Refresh token lifespan: ${currentRealm.ssoSessionMaxLifespan} seconds`); + console.log(` - SSO session idle: ${currentRealm.ssoSessionIdleTimeout} seconds`); + + // Update realm settings + await kcAdminClient.realms.update( + { realm }, + { + ...currentRealm, + // Access token settings + accessTokenLifespan: accessTokenLifespan, + accessTokenLifespanForImplicitFlow: accessTokenLifespan, + // Refresh token settings + refreshTokenMaxReuse: 0, + ssoSessionMaxLifespan: refreshTokenLifespan, + ssoSessionIdleTimeout: Math.min(refreshTokenLifespan, 1800), // Max 30 minutes idle + // Other token settings + offlineSessionMaxLifespan: refreshTokenLifespan * 2, + offlineSessionMaxLifespanEnabled: true, + // Client session settings + clientSessionMaxLifespan: accessTokenLifespan, + clientSessionIdleTimeout: Math.min(accessTokenLifespan, 1800), + } + ); + + console.log("✓ Realm token settings updated successfully"); + + // Verify the changes + const updatedRealm = await kcAdminClient.realms.findOne({ realm }); + console.log(`Updated settings:`); + console.log(` - Access token lifespan: ${updatedRealm?.accessTokenLifespan} seconds`); + console.log(` - Refresh token lifespan: ${updatedRealm?.ssoSessionMaxLifespan} seconds`); + console.log(` - SSO session idle: ${updatedRealm?.ssoSessionIdleTimeout} seconds`); + + } catch (error) { + console.error("✗ Failed to update realm token settings:", error); + process.exit(1); + } +}; + +main().catch(console.error); \ No newline at end of file