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");