From 7d0428085e1abee4f1eb9c23a514d05a3170cdfb Mon Sep 17 00:00:00 2001 From: Guillaume Beraudo Date: Tue, 12 Nov 2024 17:35:07 +0100 Subject: [PATCH 1/4] Exclude lib directory --- .vscode/settings.json | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .vscode/settings.json diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..b5a41b9 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "files.exclude": { + "lib": true + } +} From cc6150289cb0f63be5daec7de690250a4c5850cf Mon Sep 17 00:00:00 2001 From: Guillaume Beraudo Date: Tue, 12 Nov 2024 17:33:56 +0100 Subject: [PATCH 2/4] Some changes to try to use Google identity We have tested so far with keycloak. Google identity have extra options. For example, - it will not return you a refresh_token unless you pass an access_type=offline parameter to the auth request. - it requires a clientSecret, ... not very reasonable for a frontend app; - it does not support PKCE, so no extra security; - it does not have the roles claim; - it does not return a JWT token for the access_token but an opaque string; - it only returns a refresh token when sending some special parameter, and the received token is opaque. Due to the opaqueness of these tokens I changed the way the expiration is checked. --- demo.js | 59 ++++++++++++++++++++------------------ index.html | 5 ++-- src/code.ts | 81 +++++++++++++++++++++++++++++++++++++++++++---------- 3 files changed, 100 insertions(+), 45 deletions(-) diff --git a/demo.js b/demo.js index 887e37c..8ebd3e7 100644 --- a/demo.js +++ b/demo.js @@ -1,5 +1,28 @@ import CodeOIDCClient from "./lib/index.js"; +// These are test envs +const envs = { + google: { + wellKnown: { + // See https://developers.google.com/identity/openid-connect/openid-connect#discovery + authorization_endpoint: "https://accounts.google.com/o/oauth2/v2/auth", + token_endpoint: "https://oauth2.googleapis.com/token", + }, + options: { + // This is the URI that keycloak will use to finish the authentication process + // It must be an exact URL, not a prefix. + redirectUri: "http://localhost:8000/", + // The client ID is provided by your SSO server + clientId: "494959279176-2tq0hm0i4u36c60olsnmng9sfpeqs8m1.apps.googleusercontent.com", + // PKCE is an optional security feature, that must be enabled in your SSO server. + clientSecret: "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", + accessType: "offline", + pkce: false, + debug: true, + }, + }, +}; + /** * For this demo we support connecting to integration and production Keycloaks. * In a real application you can directly instantiate the client with the correct configuration. @@ -7,36 +30,16 @@ import CodeOIDCClient from "./lib/index.js"; * @return {CodeOICClient} */ function createClient(env) { - const mainURL = - env === "prod" - ? "https://login.schweizmobil.ch/realms/smobil" - : "https://keycloak.qa.fastforward.ch/realms/smobil-staging"; - - const wellKnown = { - authorization_endpoint: `${mainURL}/protocol/openid-connect/auth?prompt=login`, - token_endpoint: `${mainURL}/protocol/openid-connect/token`, - logout_endpoint: `${mainURL}/protocol/openid-connect/logout`, - }; - - const client = new CodeOIDCClient( - { - // This is the URI that keycloak will use to finish the authentication process - // It must be an exact URL, not a prefix. - redirectUri: "http://localhost:8000/", - // The client ID is provided by your SSO server - clientId: "schweizmobil-website", - // PKCE is an optional security feature, that must be enabled in your SSO server. - pkce: true, - }, - // You can create the well-known configuration yourself or retrieve it from your SSO server. - wellKnown, - ); + const envConfig = envs.google; + const client = new CodeOIDCClient(envConfig.options, envConfig.wellKnown); return client; } const env = localStorage.getItem("env") || "staging"; let client = createClient(env); +window.client = client; +console.log("For the demo, access the client from window.client"); document.querySelector("#env").addEventListener("change", (evt) => { const env = evt.target.selectedOptions[0].value; localStorage.setItem("env", env); @@ -50,9 +53,9 @@ try { localStorage.removeItem("app_preLogoutURL"); document.location = preLogoutUrl; } else { - await client.handleStateIfInURL(new URLSearchParams(document.location.search)).then(async (status) => { + await client.handleStateIfInURL(new URLSearchParams(document.location.search)).then(async (statusResult) => { const resultElement = document.querySelector("#result"); - switch (status) { + switch (statusResult.status) { case "completed": { console.log("Authentication just completed"); const preLoginUrl = localStorage.getItem("app_preLoginURL"); @@ -65,7 +68,7 @@ try { } case "invalid": case "error": { - resultElement.innerText = status.msg; + resultElement.innerText = statusResult.msg; return; } } @@ -89,7 +92,7 @@ document.querySelector("#login").addEventListener("click", async () => { localStorage.clear(); localStorage.setItem("app_preLoginURL", document.location.href); try { - const loginURL = await client.createAuthorizeAndUpdateLocalStorage(["openid", "roles"]); + const loginURL = await client.createAuthorizeAndUpdateLocalStorage(["openid"]); document.location = loginURL; } catch (error) { console.error("Error:", error); diff --git a/index.html b/index.html index 6f69cd1..fc1e0d9 100644 --- a/index.html +++ b/index.html @@ -7,8 +7,9 @@
diff --git a/src/code.ts b/src/code.ts index 0b52d3f..4c08bd4 100644 --- a/src/code.ts +++ b/src/code.ts @@ -2,7 +2,7 @@ import { generateRandomString, base64urlEncode, sha256, base64urlDecode } from " /** * The result of handling the state in the URL. - * - "finished" means the authentication process has finished successfully; + * - "completed" means the authentication process has finished successfully; * - "nothing" means that there was no state in the URL to handle; * - "invalid" means that there was a mismatch between the stored state and URL params; * - "error" means there was an error. @@ -37,7 +37,8 @@ interface JWTPayload { export interface WellKnownConfig { authorization_endpoint: string; token_endpoint: string; - logout_endpoint: string; + // Logout endpoint if existing + logout_endpoint?: string; } /** @@ -45,6 +46,8 @@ export interface WellKnownConfig { */ export interface CodeOICClientOptions { clientId: string; + clientSecret?: string; + accessType?: string; redirectUri: string; pkce?: boolean; checkToken?: (token: string) => Promise; @@ -63,6 +66,7 @@ type AuthorizationRequest = { nonce: string; code_challenge?: string; code_challenge_method?: string; + access_type?: string; }; /** @@ -71,6 +75,7 @@ type AuthorizationRequest = { type TokenRequest = { grant_type: "authorization_code"; client_id: string; + client_secret?: string; redirect_uri: string; code: string; code_verifier?: string; @@ -79,6 +84,7 @@ type TokenRequest = { type RefreshTokenRequest = { grant_type: "refresh_token"; client_id: string; + client_secret?: string; refresh_token: string; }; @@ -86,7 +92,7 @@ type TokenResponse = { access_token: string; token_type: string; expires_in: number; - refresh_token: string; + refresh_token?: string; id_token: string; }; @@ -213,6 +219,9 @@ export class CodeOIDCClient { redirect_uri: this.options.redirectUri, code: code, }; + if (this.options.clientSecret) { + params.client_secret = this.options.clientSecret; + } if (this.options.pkce) { if (debug) { console.log("Using PKCE"); @@ -237,9 +246,12 @@ export class CodeOIDCClient { body: new URLSearchParams(params), }); const data: TokenResponse = await response.json(); - const { access_token, id_token, refresh_token } = data; - if (!access_token || !refresh_token || !id_token) { - return Promise.reject("Did not reveive tokens"); + const { access_token, id_token, refresh_token, expires_in } = data; + if (!access_token || !id_token) { + return Promise.reject("Did not reveive id or access tokens"); + } + if (!refresh_token) { + console.log("We received no refresh token"); } if (this.options.checkToken) { const valid = await this.options.checkToken(access_token); @@ -247,9 +259,14 @@ export class CodeOIDCClient { return Promise.reject("Token is not valid"); } } + const expiresAt = Date.now() / 1000 + (expires_in ? expires_in : 3_600 * 24 * 365 * 5); this.lset("access_token", access_token); this.lset("refresh_token", refresh_token); + this.lset("access_token_expires_at", expiresAt.toString()); this.lset("id_token", id_token); + if (this.options.debug) { + console.log("doTokenQuery", access_token); + } return access_token; } @@ -273,6 +290,9 @@ export class CodeOIDCClient { state: state, nonce: nonce, }; + if (this.options.accessType) { + params.access_type = this.options.accessType; + } if (this.options.pkce) { if (debug) { console.log("Using a PKCE authorization"); @@ -297,16 +317,21 @@ export class CodeOIDCClient { * * @param token A well-formed token * @return the parsed payload or undefined if the token is not well-formed + * @throws if not a well formed JWT token or not in a secured browsing context */ parseJwtPayload(token: string): JWTPayload { + if (!token) { + return null; + } try { const base64Url = token.split(".")[1]; const buffer = base64urlDecode(base64Url); const decoder = new TextDecoder(); const payload = decoder.decode(buffer); return JSON.parse(payload); - } catch { - return undefined; + } catch (e) { + console.error("Could not parse token", token); + throw e; } } @@ -316,22 +341,36 @@ export class CodeOIDCClient { client_id: this.options.clientId, refresh_token: refreshToken, }; + if (this.options.clientSecret) { + params.client_secret = this.options.clientSecret; + } return this.doTokenQuery(params); } - isActive(token: string): boolean { + /** + * @deprecated all tokens are not JWT + * @param token + * @returns + */ + isActiveToken(token: string): boolean { const payload = this.parseJwtPayload(token); if (!payload) { return false; } + + return this.isInsideValidityPeriod(payload.exp, 30); + } + + protected isInsideValidityPeriod(expiration: number, leeway = 30): boolean { // Substract 30 seconds to the token expiration time // to eagerly renew the token and give us some margin. // This is necessary to account of clock discrepency between client and server. // Ideally, the server also tolerate some leeway. - return payload.exp - 30 > Date.now() / 1000; + return expiration - leeway > Date.now() / 1000; } async getActiveToken(): Promise { + const expiresAt = this.lget("access_token_expires_at"); const accessToken = this.lget("access_token"); const debug = this.options.debug; if (!accessToken) { @@ -340,7 +379,16 @@ export class CodeOIDCClient { } return ""; } - if (this.isActive(accessToken)) { + if (!expiresAt) { + if (debug) { + console.log("No expires_at found"); + } + return ""; + } + + // Access tokens are not guaranteed to be JWT so are not inspectable. + // Instead, we use the companion expires_in property. Note that it may still fail if the token was revoked. + if (this.isInsideValidityPeriod(Number.parseInt(expiresAt))) { return accessToken; } const refreshToken = this.lget("refresh_token"); @@ -350,10 +398,8 @@ export class CodeOIDCClient { } return ""; } - if (this.isActive(refreshToken)) { - return this.refreshToken(refreshToken); - } - return ""; + // There is no reliable way to check refresh token validity in advance, so just try using it. + return this.refreshToken(refreshToken); } getActiveIdToken(): string { @@ -361,6 +407,11 @@ export class CodeOIDCClient { } logout(document: Document) { + if (!this.wellKnown.logout_endpoint) { + console.log("No logout endpoint"); + this.lclear(); + return; + } const activeIdToken = this.getActiveIdToken(); if (!activeIdToken) { console.error("No active id token found"); From d8c8b7ee956e590cf07b006a76bd67ebe3d98472 Mon Sep 17 00:00:00 2001 From: Guillaume Beraudo Date: Thu, 21 Nov 2024 12:07:36 +0100 Subject: [PATCH 3/4] Get scopes from config and improve demo --- demo.js | 51 +++++++++++++++++++++++++++++++++++++++++++++------ index.html | 6 +----- src/code.ts | 5 +++-- 3 files changed, 49 insertions(+), 13 deletions(-) diff --git a/demo.js b/demo.js index 8ebd3e7..bc936ef 100644 --- a/demo.js +++ b/demo.js @@ -16,34 +16,72 @@ const envs = { clientId: "494959279176-2tq0hm0i4u36c60olsnmng9sfpeqs8m1.apps.googleusercontent.com", // PKCE is an optional security feature, that must be enabled in your SSO server. clientSecret: "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", + scopes: ["openid"], accessType: "offline", pkce: false, debug: true, }, }, + gmfngv: { + wellKnown: { + authorization_endpoint: "https://sso.geomapfish-demo.prod.apps.gs-ch-prod.camptocamp.com/oauth/v2/authorize", + token_endpoint: "https://sso.geomapfish-demo.prod.apps.gs-ch-prod.camptocamp.com/oauth/v2/token", + }, + options: { + redirectUri: "http://localhost:8000/", + clientId: "294600834753305656", + scopes: ["openid", "offline_access"], + pkce: true, + debug: true, + }, + }, + c2cngv: { + wellKnown: { + authorization_endpoint: "https://sso.idm.camptocamp.com/auth/realms/sandbox/protocol/openid-connect/auth", + token_endpoint: "https://sso.idm.camptocamp.com/auth/realms/sandbox/protocol/openid-connect/token", + }, + options: { + redirectUri: "http://localhost:8000/", + clientId: "ngv-labs", + scopes: ["openid", "email", "profile"], + pkce: true, + debug: true, + }, + }, }; /** * For this demo we support connecting to integration and production Keycloaks. * In a real application you can directly instantiate the client with the correct configuration. - * @param {string} env + * @param {string} envName * @return {CodeOICClient} */ -function createClient(env) { - const envConfig = envs.google; +function createClient(envName) { + const envConfig = envs[envName]; const client = new CodeOIDCClient(envConfig.options, envConfig.wellKnown); return client; } -const env = localStorage.getItem("env") || "staging"; +console.log("Env from storage", localStorage.getItem("env")); +let env = localStorage.getItem("env") || "gmfngv"; +const envSelect = document.querySelector("#env"); +for (const key in envs) { + const option = document.createElement("option"); + option.value = key; + option.text = key; + option.selected = key === env; + envSelect.appendChild(option); +} + let client = createClient(env); window.client = client; console.log("For the demo, access the client from window.client"); document.querySelector("#env").addEventListener("change", (evt) => { - const env = evt.target.selectedOptions[0].value; + env = evt.target.selectedOptions[0].value; localStorage.setItem("env", env); client = createClient(env); + console.log("Created client for", env); }); try { @@ -90,9 +128,10 @@ try { // Initiate the login process when the user clicks the login button document.querySelector("#login").addEventListener("click", async () => { localStorage.clear(); + localStorage.setItem("env", env); localStorage.setItem("app_preLoginURL", document.location.href); try { - const loginURL = await client.createAuthorizeAndUpdateLocalStorage(["openid"]); + const loginURL = await client.createAuthorizeAndUpdateLocalStorage(); document.location = loginURL; } catch (error) { console.error("Error:", error); diff --git a/index.html b/index.html index fc1e0d9..76183cf 100644 --- a/index.html +++ b/index.html @@ -6,11 +6,7 @@
- + diff --git a/src/code.ts b/src/code.ts index 4c08bd4..dd5527a 100644 --- a/src/code.ts +++ b/src/code.ts @@ -47,6 +47,7 @@ export interface WellKnownConfig { export interface CodeOICClientOptions { clientId: string; clientSecret?: string; + scopes?: [string]; accessType?: string; redirectUri: string; pkce?: boolean; @@ -274,7 +275,7 @@ export class CodeOIDCClient { * Start a log in process. * This will redirect to the SSO and back to the provided redirect URI. */ - async createAuthorizeAndUpdateLocalStorage(scopes: string[]): Promise { + async createAuthorizeAndUpdateLocalStorage(scopes?: string[]): Promise { const debug = this.options.debug; if (debug) { console.log("Starting loging process"); @@ -286,7 +287,7 @@ export class CodeOIDCClient { response_type: "code", client_id: this.options.clientId, redirect_uri: this.options.redirectUri, - scope: scopes.join(" "), + scope: (scopes || this.options.scopes).join(" "), state: state, nonce: nonce, }; From 7a70ee7a04ecb30ff07abf30e6d0fef3d6d921f2 Mon Sep 17 00:00:00 2001 From: Guillaume Beraudo Date: Thu, 21 Nov 2024 12:07:59 +0100 Subject: [PATCH 4/4] 0.5.11 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8b8f90f..e8fe9ff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@geoblocks/oidcjs", - "version": "0.5.10", + "version": "0.5.11", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@geoblocks/oidcjs", - "version": "0.5.10", + "version": "0.5.11", "license": "BSD-2-Clause", "devDependencies": { "@biomejs/biome": "1.8.2", diff --git a/package.json b/package.json index 6110489..dde810e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@geoblocks/oidcjs", - "version": "0.5.10", + "version": "0.5.11", "description": "A simple OpenID Connect client typescript implementation", "scripts": { "start": "python3 -m http.server 8000 --bind localhost",