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 + } +} diff --git a/demo.js b/demo.js index 887e37c..bc936ef 100644 --- a/demo.js +++ b/demo.js @@ -1,46 +1,87 @@ import CodeOIDCClient from "./lib/index.js"; -/** - * 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 - * @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( - { +// 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: "schweizmobil-website", + 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, }, - // You can create the well-known configuration yourself or retrieve it from your SSO server. - wellKnown, - ); + }, +}; + +/** + * 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} envName + * @return {CodeOICClient} + */ +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 { @@ -50,9 +91,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 +106,7 @@ try { } case "invalid": case "error": { - resultElement.innerText = status.msg; + resultElement.innerText = statusResult.msg; return; } } @@ -87,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", "roles"]); + const loginURL = await client.createAuthorizeAndUpdateLocalStorage(); document.location = loginURL; } catch (error) { console.error("Error:", error); diff --git a/index.html b/index.html index 6f69cd1..76183cf 100644 --- a/index.html +++ b/index.html @@ -6,10 +6,7 @@
- + 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", diff --git a/src/code.ts b/src/code.ts index 0b52d3f..dd5527a 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,9 @@ export interface WellKnownConfig { */ export interface CodeOICClientOptions { clientId: string; + clientSecret?: string; + scopes?: [string]; + accessType?: string; redirectUri: string; pkce?: boolean; checkToken?: (token: string) => Promise; @@ -63,6 +67,7 @@ type AuthorizationRequest = { nonce: string; code_challenge?: string; code_challenge_method?: string; + access_type?: string; }; /** @@ -71,6 +76,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 +85,7 @@ type TokenRequest = { type RefreshTokenRequest = { grant_type: "refresh_token"; client_id: string; + client_secret?: string; refresh_token: string; }; @@ -86,7 +93,7 @@ type TokenResponse = { access_token: string; token_type: string; expires_in: number; - refresh_token: string; + refresh_token?: string; id_token: string; }; @@ -213,6 +220,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 +247,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 +260,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; } @@ -257,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"); @@ -269,10 +287,13 @@ 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, }; + if (this.options.accessType) { + params.access_type = this.options.accessType; + } if (this.options.pkce) { if (debug) { console.log("Using a PKCE authorization"); @@ -297,16 +318,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 +342,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 +380,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 +399,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 +408,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");