From 05272e191edd74ef5412852ce126ea4fc8cb9c11 Mon Sep 17 00:00:00 2001 From: Arturo Reuschenbach Puncernau Date: Tue, 29 Sep 2026 13:21:05 +0200 Subject: [PATCH 01/16] feat(oauth): add comprehensive TypeScript type definitions Signed-off-by: Arturo Reuschenbach Puncernau --- packages/oauth/src/index.ts | 19 ++ packages/oauth/src/types.ts | 337 ++++++++++++++++++++++++++++++++++++ 2 files changed, 356 insertions(+) create mode 100644 packages/oauth/src/types.ts diff --git a/packages/oauth/src/index.ts b/packages/oauth/src/index.ts index 29b02eb04f..cb56aa2b78 100644 --- a/packages/oauth/src/index.ts +++ b/packages/oauth/src/index.ts @@ -6,4 +6,23 @@ import oidcSession from "./oidcSession" import mockedSession from "./mockedSession" import tokenSession from "./tokenSession" + export { oidcSession, mockedSession, tokenSession } + +// Export types for consumers +export type { + AuthData, + ParsedTokenData, + IdTokenData, + SessionState, + FlowType, + OidcConfig, + OidcSessionParams, + OidcSessionInstance, + TokenSessionParams, + TokenSessionInstance, + MockedSessionParams, + MockedSessionInstance, + TokenResponse, + OidcStateData, +} from "./types" diff --git a/packages/oauth/src/types.ts b/packages/oauth/src/types.ts new file mode 100644 index 0000000000..f34575e8da --- /dev/null +++ b/packages/oauth/src/types.ts @@ -0,0 +1,337 @@ +/* + * SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and Juno contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * OAuth flow types supported by this library + */ +export type FlowType = "implicit" | "code" + +/** + * OpenID Connect Discovery Configuration + * Based on: https://openid.net/specs/openid-connect-discovery-1_0.html + */ +export interface OidcConfig { + /** REQUIRED. URL of the OP's OAuth 2.0 Authorization Endpoint */ + authorization_endpoint: string + /** REQUIRED. URL of the OP's OAuth 2.0 Token Endpoint */ + token_endpoint: string + /** URL of the OP's OAuth 2.0 Revocation Endpoint */ + revocation_endpoint?: string + /** URL of the OP's OAuth 2.0 Introspection Endpoint */ + introspection_endpoint?: string + /** RECOMMENDED. URL of the OP's UserInfo Endpoint */ + userinfo_endpoint?: string + /** URL of the OP's logout endpoint */ + end_session_endpoint?: string + /** RECOMMENDED. JSON array containing a list of the OAuth 2.0 [RFC6749] scope values supported */ + scopes_supported?: string[] + /** REQUIRED. JSON array containing a list of the OAuth 2.0 response_type values supported */ + response_types_supported?: string[] + /** JSON array containing a list of the OAuth 2.0 grant type values supported */ + grant_types_supported?: string[] + /** RECOMMENDED. URL of the OP's JWK Set document */ + jwks_uri?: string + /** REQUIRED. URL of the OP's OpenID Provider Issuer */ + issuer?: string + /** Allows additional fields from OIDC Discovery spec */ + [key: string]: unknown +} + +/** + * Cached OIDC configuration with timestamp + */ +export interface CachedConfig { + time: number + config: OidcConfig +} + +/** + * Raw ID Token data (JWT payload) + * Based on OpenID Connect Core 1.0 and RFC 7519 + */ +export interface IdTokenData { + /** Issuer Identifier */ + iss?: string + /** Subject Identifier */ + sub?: string + /** Audience(s) */ + aud?: string | string[] + /** Expiration time (seconds since epoch) */ + exp?: number + /** Issued at time (seconds since epoch) */ + iat?: number + /** Time when authentication occurred */ + auth_time?: number + /** String value used to associate a Client session with an ID Token */ + nonce?: string + /** Authentication Context Class Reference */ + acr?: string + /** Authentication Methods References */ + amr?: string[] + /** Authorized party */ + azp?: string + /** Email address */ + email?: string + /** Email verified flag */ + email_verified?: boolean + /** User's full name */ + name?: string + /** Given name(s) or first name(s) */ + given_name?: string + /** Surname(s) or last name(s) */ + family_name?: string + /** First name */ + first_name?: string + /** Last name */ + last_name?: string + /** Preferred username */ + preferred_username?: string + /** Login name */ + login_name?: string + /** User's full name in displayable form */ + subject?: string + /** Email */ + mail?: string + /** Groups the user belongs to */ + groups?: string[] + /** Allows additional claims */ + [key: string]: unknown +} + +/** + * Parsed token data with extracted user information + */ +export interface ParsedTokenData { + loginName: string + email: string + firstName: string + lastName: string + fullName: string + expiresAt: number + expiresAtDate: Date + groups: string[] | undefined + userId: string | null + avatarUrl: { + small: string + large: string + default: string + } + organizations?: string[] + teams?: string[] + roles?: string[] + supportGroups?: string[] +} + +/** + * Complete authentication data structure + */ +export interface AuthData { + /** The JWT token string */ + JWT: string + /** Raw token data (decoded JWT payload) */ + raw: IdTokenData + /** Refresh token (if available) */ + refreshToken?: string + /** Parsed and processed token data */ + parsed: ParsedTokenData +} + +/** + * Session state using discriminated union for type safety + * Inspired by greenhouse-auth-provider pattern + */ +export type SessionState = + | { + loggedIn: false + auth: null + error: string | null + isProcessing: boolean + } + | { + loggedIn: true + auth: AuthData + error: string | null + isProcessing: boolean + } + +/** + * OIDC state data stored during OAuth flow + */ +export interface OidcStateData { + /** Unique key for this state */ + key: string + /** Nonce value for security */ + nonce: string + /** URL to return to after OAuth flow */ + lastUrl?: string + /** Flow type being used */ + flowType?: FlowType + /** PKCE verifier (for code flow) */ + verifier?: string + /** PKCE challenge (for code flow) */ + challenge?: string + /** Allows additional state properties */ + [key: string]: unknown +} + +/** + * OAuth token response from token endpoint + * Based on RFC 6749 Section 5.1 + */ +export interface TokenResponse { + /** The access token issued by the authorization server */ + access_token: string + /** The type of the token (e.g., "Bearer") */ + token_type: string + /** The lifetime in seconds of the access token */ + expires_in?: number + /** The refresh token (if issued) */ + refresh_token?: string + /** The scope of the access token */ + scope?: string + /** The ID token (OpenID Connect) */ + id_token?: string + /** Allows additional fields from token endpoint response */ + [key: string]: unknown +} + +/** + * Result from OAuth flow handlers (implicit or code flow) + */ +export interface FlowResponse { + /** Decoded token data */ + tokenData: IdTokenData + /** The ID token string */ + idToken: string + /** The refresh token (if available) */ + refreshToken?: string | null +} + +/** + * Parameters for building OAuth request URL + */ +export interface BuildRequestUrlParams { + issuerURL: string + clientID: string + oidcState: OidcStateData + callbackURL?: string + params?: Record +} + +/** + * Parameters for handling OAuth response + */ +export interface HandleResponseParams { + issuerURL: string + clientID: string + oidcState: OidcStateData +} + +/** + * Parameters for exchanging authorization code for tokens + */ +export interface ExchangeCodeParams { + tokenEndpoint: string + code: string + verifier?: string + clientID: string +} + +/** + * Parameters for refreshing tokens + */ +export interface RefreshTokenParams { + issuerURL: string + clientID: string + refreshToken: string +} + +/** + * OIDC session configuration parameters + */ +export interface OidcSessionParams { + /** OIDC issuer URL */ + issuerURL: string + /** OAuth client ID */ + clientID: string + /** Whether to initiate login immediately */ + initialLogin?: boolean + /** Whether to automatically refresh tokens */ + refresh?: boolean + /** OAuth flow type to use */ + flowType?: FlowType + /** Callback function called on auth state updates */ + onUpdate?: (state: SessionState) => void + /** Additional request parameters */ + requestParams?: string | Record + /** Internal: callback URL override */ + _callbackURL?: string +} + +/** + * OIDC session instance (return value) + */ +export interface OidcSessionInstance { + /** Initiate login flow */ + login: () => void + /** Logout user */ + logout: (options?: { resetOIDCSession?: boolean; silent?: boolean }) => void + /** Manually refresh token */ + refresh: () => void + /** Get current session state */ + currentState: () => SessionState +} + +/** + * Token session configuration parameters + */ +export interface TokenSessionParams { + /** JWT token string */ + token: string + /** Additional options to extend token data */ + options?: Record + /** Whether to trigger login immediately */ + initialLogin?: boolean + /** Callback function called on auth state updates */ + onUpdate: (state: SessionState) => void +} + +/** + * Token session instance (return value) + */ +export interface TokenSessionInstance { + /** Trigger login */ + login: () => void + /** Trigger logout */ + logout: () => void + /** Get current session state */ + currentState: () => SessionState +} + +/** + * Mocked session configuration parameters + */ +export interface MockedSessionParams { + /** Mock token data (string or object) */ + token?: string | Record + /** Whether to trigger login immediately */ + initialLogin?: boolean + /** Callback function called on auth state updates */ + onUpdate: (state: SessionState) => void +} + +/** + * Mocked session instance (return value) + */ +export interface MockedSessionInstance { + /** Trigger login */ + login: () => void + /** Trigger logout */ + logout: () => void + /** Manually refresh token */ + refresh: () => void + /** Get current session state */ + currentState: () => SessionState +} From 46ab6a946a05f944c5a84ef006518c9c2ec9896b Mon Sep 17 00:00:00 2001 From: Arturo Reuschenbach Puncernau Date: Tue, 29 Sep 2026 13:24:23 +0200 Subject: [PATCH 02/16] fix(ui): fixed no-unused-vars Signed-off-by: Arturo Reuschenbach Puncernau --- packages/oauth/src/types.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/oauth/src/types.ts b/packages/oauth/src/types.ts index f34575e8da..8bf3150cb1 100644 --- a/packages/oauth/src/types.ts +++ b/packages/oauth/src/types.ts @@ -263,7 +263,7 @@ export interface OidcSessionParams { /** OAuth flow type to use */ flowType?: FlowType /** Callback function called on auth state updates */ - onUpdate?: (state: SessionState) => void + onUpdate?: (_state: SessionState) => void /** Additional request parameters */ requestParams?: string | Record /** Internal: callback URL override */ @@ -277,7 +277,7 @@ export interface OidcSessionInstance { /** Initiate login flow */ login: () => void /** Logout user */ - logout: (options?: { resetOIDCSession?: boolean; silent?: boolean }) => void + logout: (_options?: { resetOIDCSession?: boolean; silent?: boolean }) => void /** Manually refresh token */ refresh: () => void /** Get current session state */ @@ -295,7 +295,7 @@ export interface TokenSessionParams { /** Whether to trigger login immediately */ initialLogin?: boolean /** Callback function called on auth state updates */ - onUpdate: (state: SessionState) => void + onUpdate: (_state: SessionState) => void } /** @@ -319,7 +319,7 @@ export interface MockedSessionParams { /** Whether to trigger login immediately */ initialLogin?: boolean /** Callback function called on auth state updates */ - onUpdate: (state: SessionState) => void + onUpdate: (_state: SessionState) => void } /** From 70d8f9d12506160cc7c4b2761f05629a66e822ea Mon Sep 17 00:00:00 2001 From: Arturo Reuschenbach Puncernau Date: Tue, 29 Sep 2026 13:44:02 +0200 Subject: [PATCH 03/16] feat(oauth): add TypeScript types to utility functions Signed-off-by: Arturo Reuschenbach Puncernau --- packages/oauth/src/oidcState.ts | 2 +- packages/oauth/src/utils.ts | 26 ++++++++++++++++---------- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/packages/oauth/src/oidcState.ts b/packages/oauth/src/oidcState.ts index 499fa39dcd..0cac8fc405 100644 --- a/packages/oauth/src/oidcState.ts +++ b/packages/oauth/src/oidcState.ts @@ -44,7 +44,7 @@ if (!stateString) { if (stateString) { // return if state exists // decode catches parse errors and returns null - state = decodeBase64Json(stateString) + state = decodeBase64Json(stateString) as OidcState | null window.sessionStorage.removeItem(state!.key) } diff --git a/packages/oauth/src/utils.ts b/packages/oauth/src/utils.ts index 4d3ec039fd..4a3b62e4d7 100644 --- a/packages/oauth/src/utils.ts +++ b/packages/oauth/src/utils.ts @@ -5,7 +5,8 @@ /** * This function generates a random string based on Math.random - * @returns {string} random string + * @param length - Length of the random string (default: 60) + * @returns random string */ export function randomString(length: number = 60): string { let result = "" @@ -20,18 +21,18 @@ export function randomString(length: number = 60): string { } /** - * - * @param {object} props + * Encode a value as base64 JSON string + * @param props - Value to encode * @returns base64 encoded json string */ -export const encodeBase64Json = (props: any): string => window.btoa(JSON.stringify({ ...props })) +export const encodeBase64Json = (props: unknown): string => window.btoa(JSON.stringify(props)) /** - * - * @param {string} string, base64 encoded json string - * @returns {object} json + * Decode a base64 encoded JSON string + * @param string - base64 encoded json string + * @returns Decoded value or null if decoding fails */ -export const decodeBase64Json = (string: string): any => { +export const decodeBase64Json = (string: string): unknown => { try { return JSON.parse(window.atob(string)) } catch (_) { @@ -39,8 +40,13 @@ export const decodeBase64Json = (string: string): any => { } } -export const paramsToUrl = (params: any = {}): string => { +/** + * Convert an object to URL search parameters string + * @param params - Object with string, number, or boolean values + * @returns URL-encoded search parameters string + */ +export const paramsToUrl = (params: Record = {}): string => { const urlParams = new URLSearchParams() - Object.keys(params).forEach((k) => urlParams.set(k, params[k])) + Object.keys(params).forEach((k) => urlParams.set(k, String(params[k]))) return urlParams.toString() } From 439090514174777b90234937081be2bb61da337f Mon Sep 17 00:00:00 2001 From: Arturo Reuschenbach Puncernau Date: Tue, 29 Sep 2026 13:53:27 +0200 Subject: [PATCH 04/16] feat(oauth): add TypeScript types to token helpers and config Signed-off-by: Arturo Reuschenbach Puncernau --- packages/oauth/src/oidcConfig.ts | 23 +++++++++---------- packages/oauth/src/tokenHelpers.ts | 36 ++++++++++++++++-------------- packages/oauth/src/tokenSession.ts | 5 +++-- 3 files changed, 32 insertions(+), 32 deletions(-) diff --git a/packages/oauth/src/oidcConfig.ts b/packages/oauth/src/oidcConfig.ts index 19e9896c0c..c7f34cb0c2 100644 --- a/packages/oauth/src/oidcConfig.ts +++ b/packages/oauth/src/oidcConfig.ts @@ -3,20 +3,17 @@ * SPDX-License-Identifier: Apache-2.0 */ -type OidcConfig = Record +import type { OidcConfig, CachedConfig } from "./types" -interface CachedConfig { - time: number - config: OidcConfig -} - -let oidcConfig: Record = {} +let oidcConfig: Record = {} const cacheDuration = 5 * 60 * 60 * 1000 -export async function getOidcConfig(issuerURL: any): Promise { +export async function getOidcConfig(issuerURL: string | URL): Promise { // throw an error if no issuerURL is provided if (!issuerURL) throw new Error("No issuerURL provided") - const cachedConfig = oidcConfig[issuerURL] + + const issuerKey = String(issuerURL) + const cachedConfig = oidcConfig[issuerKey] // return cached config if it exists and is less than 5 minutes old if (cachedConfig?.time && cachedConfig?.time > Date.now() - cacheDuration) return cachedConfig.config @@ -28,12 +25,12 @@ export async function getOidcConfig(issuerURL: any): Promise { // double slashes from the path url.pathname = (url.pathname + "/.well-known/openid-configuration").replace(/\/\/+/g, "/") - return fetch(url).then((r) => { - oidcConfig[issuerURL] = { - config: r.json(), + return fetch(url).then(async (r) => { + oidcConfig[issuerKey] = { + config: await r.json(), time: Date.now(), } - return oidcConfig[issuerURL].config + return oidcConfig[issuerKey].config }) } diff --git a/packages/oauth/src/tokenHelpers.ts b/packages/oauth/src/tokenHelpers.ts index f672cc1b67..03d89dfab2 100644 --- a/packages/oauth/src/tokenHelpers.ts +++ b/packages/oauth/src/tokenHelpers.ts @@ -3,15 +3,17 @@ * SPDX-License-Identifier: Apache-2.0 */ +import type { IdTokenData, ParsedTokenData } from "./types" import { decodeBase64Json } from "./utils" + /** * Extract auth data from id_token - * @param {string} idToken JWT - * @returns {object} json + * @param idToken - JWT token string + * @returns Decoded token data */ -export function decodeIDToken(idToken: string): any { +export function decodeIDToken(idToken: string): IdTokenData { const [_head, tokenData, _signature] = idToken.split(".") - return decodeBase64Json(tokenData) + return decodeBase64Json(tokenData) as IdTokenData } const capitalize = (str: string): string => { @@ -38,23 +40,24 @@ const extractNameFromEmail = (email: string): EmailName | null | undefined => { let lastName = emailName.substring(index + 1) firstName = firstName .split("-") - .map((t: any) => capitalize(t)) + .map((t: string) => capitalize(t)) .join("-") lastName = lastName .split(".") - .map((t: any) => capitalize(t)) + .map((t: string) => capitalize(t)) .join(" ") return { firstName, lastName } } catch (_) { console.info("(OAUTH) could not determine first and last names") } } + /** - * - * @param {object} tokenData - * @returns {object} parsed data + * Parse and process ID token data into user information + * @param tokenData - Raw ID token payload + * @returns Parsed token data with user information */ -export function parseIdTokenData(tokenData: any): any { +export function parseIdTokenData(tokenData: IdTokenData): ParsedTokenData { const email = tokenData.mail || tokenData.email || "" const loginName = tokenData.login_name || tokenData.name || tokenData.subject || tokenData.sub || "" let firstName = tokenData.first_name @@ -67,14 +70,14 @@ export function parseIdTokenData(tokenData: any): any { const regex = new RegExp("^[c,d,i,s,p,C,D,I,S,P][0-9]+$") const userId = tokenData?.sub?.match(regex) ? tokenData.sub : null - const parsedData: any = { + const parsedData: ParsedTokenData = { loginName, email, - firstName, - lastName, + firstName: firstName || "", + lastName: lastName || "", fullName: `${firstName} ${lastName}`, - expiresAt: tokenData.exp * 1000, - expiresAtDate: new Date(tokenData.exp * 1000), + expiresAt: (tokenData.exp || 0) * 1000, + expiresAtDate: new Date((tokenData.exp || 0) * 1000), groups: tokenData.groups, userId: userId, avatarUrl: { @@ -85,7 +88,7 @@ export function parseIdTokenData(tokenData: any): any { } if (Array.isArray(tokenData.groups)) { - tokenData.groups.forEach((item: any) => { + tokenData.groups.forEach((item: string) => { if (item.startsWith("organization:")) { parsedData.organizations = parsedData.organizations || [] parsedData.organizations.push(item.substring("organization:".length)) @@ -104,7 +107,6 @@ export function parseIdTokenData(tokenData: any): any { } }) } - // groups: ["organization:test-org", "team:test-team-1", "team:test-team-2"], return parsedData } diff --git a/packages/oauth/src/tokenSession.ts b/packages/oauth/src/tokenSession.ts index 8e109cd603..9d0a9eceb3 100644 --- a/packages/oauth/src/tokenSession.ts +++ b/packages/oauth/src/tokenSession.ts @@ -3,6 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import type { IdTokenData, ParsedTokenData } from "./types" import { parseIdTokenData, decodeIDToken } from "./tokenHelpers" interface AuthData { @@ -13,8 +14,7 @@ interface AuthData { } export const composeAuthData = (token: string, options: any): { authData: AuthData | null; error: Error | null } => { - let tokenData: Record - let parsedData: unknown + let tokenData: IdTokenData try { tokenData = decodeIDToken(token) @@ -27,6 +27,7 @@ export const composeAuthData = (token: string, options: any): { authData: AuthDa // extend tokenData with the options tokenData = { ...tokenData, ...options } + let parsedData: ParsedTokenData try { parsedData = parseIdTokenData(tokenData) } catch (e) { From 368c30a1ef0577dcc3e01fd127175a0e403cb8aa Mon Sep 17 00:00:00 2001 From: Arturo Reuschenbach Puncernau Date: Tue, 29 Sep 2026 14:13:15 +0200 Subject: [PATCH 05/16] feat(oauth): add TypeScript types to state management and flow handlers Signed-off-by: Arturo Reuschenbach Puncernau --- packages/oauth/__tests__/codeFlow.test.ts | 17 +++--- packages/oauth/__tests__/implicitFlow.test.ts | 2 +- packages/oauth/src/codeFlow.ts | 54 ++++++++++++------- packages/oauth/src/implicitFlow.ts | 22 ++++++-- packages/oauth/src/oidcState.ts | 42 ++++++++------- 5 files changed, 88 insertions(+), 49 deletions(-) diff --git a/packages/oauth/__tests__/codeFlow.test.ts b/packages/oauth/__tests__/codeFlow.test.ts index 2ef63114ee..659aa9f0f0 100644 --- a/packages/oauth/__tests__/codeFlow.test.ts +++ b/packages/oauth/__tests__/codeFlow.test.ts @@ -37,7 +37,7 @@ describe("buildRequestUrl", () => { buildRequestUrl({ issuerURL: "http://issuer.com", clientID: "12345", - oidcState: {}, + oidcState: { key: "test-key", nonce: "test-nonce" }, }) ).toBeInstanceOf(Promise) }) @@ -136,7 +136,7 @@ describe("handleResponse", () => { handleResponse({ issuerURL: "http://issuer.com", clientID: "12345", - oidcState: {}, + oidcState: { key: "test-key", nonce: "test-nonce" }, }) ).rejects.toThrow("bad response, missing code param") }) @@ -148,7 +148,7 @@ describe("handleResponse", () => { handleResponse({ issuerURL: "http://issuer.com", clientID: "12345", - oidcState: {}, + oidcState: { key: "test-key", nonce: "test-nonce" }, }) ).rejects.toThrow("unsupported_response_type") }) @@ -157,9 +157,10 @@ describe("handleResponse", () => { oidcState.setSearchParams(new URLSearchParams("code=test")) await expect( + // @ts-expect-error - Testing missing required parameter handleResponse({ issuerURL: "http://issuer.com", - oidcState: {}, + oidcState: { key: "test-key", nonce: "test-nonce" }, }) ).rejects.toThrow("clientID is required") }) @@ -171,7 +172,7 @@ describe("handleResponse", () => { await handleResponse({ issuerURL: "http://issuer.com", clientID: "test", - oidcState: {}, + oidcState: { key: "test-key", nonce: "test-nonce" }, }) expect(getOidcConfig).toHaveBeenCalledWith("http://issuer.com") @@ -184,6 +185,8 @@ describe("handleResponse", () => { issuerURL: "http://issuer.com", clientID: "test", oidcState: { + key: "test-key", + nonce: "test-nonce", verifier: "12345", }, }) @@ -204,10 +207,10 @@ describe("handleResponse", () => { const data = await handleResponse({ issuerURL: "http://issuer.com", clientID: "test", - oidcState: {}, + oidcState: { key: "test-key", nonce: "test-nonce" }, }) - expect(data!.tokenData).toEqual(expect.objectContaining(testTokenData)) + expect(data.tokenData).toEqual(expect.objectContaining(testTokenData)) }) }) }) diff --git a/packages/oauth/__tests__/implicitFlow.test.ts b/packages/oauth/__tests__/implicitFlow.test.ts index 4abfd375c4..b2d4448f3d 100644 --- a/packages/oauth/__tests__/implicitFlow.test.ts +++ b/packages/oauth/__tests__/implicitFlow.test.ts @@ -29,7 +29,7 @@ describe("buildRequestUrl", () => { buildRequestUrl({ issuerURL: "http://issuer.com", clientID: "12345", - oidcState: {}, + oidcState: { key: "test-key", nonce: "test-nonce" }, }) ).toBeInstanceOf(Promise) }) diff --git a/packages/oauth/src/codeFlow.ts b/packages/oauth/src/codeFlow.ts index 690a9b5201..ab36232512 100644 --- a/packages/oauth/src/codeFlow.ts +++ b/packages/oauth/src/codeFlow.ts @@ -3,21 +3,12 @@ * SPDX-License-Identifier: Apache-2.0 */ +import type { TokenResponse, OidcStateData, FlowResponse } from "./types" import { getOidcConfig } from "./oidcConfig" import { decodeIDToken } from "./tokenHelpers" import { searchParams } from "./oidcState" import { paramsToUrl } from "./utils" -interface TokenResponse { - access_token: string - token_type: string - expires_in?: number - refresh_token?: string - scope?: string - id_token?: string - [key: string]: unknown // Optional: allows unexpected fields -} - interface ExchangeCodeParams { tokenEndpoint: string code: string @@ -62,7 +53,21 @@ export const exchangeCode = async ({ return data } -const buildRequestUrl = async ({ issuerURL, clientID, oidcState, params, callbackURL }: Record) => { +interface CodeFlowParams { + issuerURL: string + clientID: string + oidcState: OidcStateData + params?: Record + callbackURL?: string +} + +const buildRequestUrl = async ({ + issuerURL, + clientID, + oidcState, + params, + callbackURL, +}: CodeFlowParams): Promise => { const config = await getOidcConfig(issuerURL) let scope = "openid email profile offline_access" @@ -77,7 +82,7 @@ const buildRequestUrl = async ({ issuerURL, clientID, oidcState, params, callbac scope, state: oidcState.key, nonce: oidcState.nonce, - code_challenge: oidcState.challenge, + code_challenge: oidcState.challenge || "", code_challenge_method: "S256", ...params, }) @@ -85,8 +90,14 @@ const buildRequestUrl = async ({ issuerURL, clientID, oidcState, params, callbac return config.authorization_endpoint + "?" + urlParams } -const handleResponse = async ({ issuerURL, clientID, oidcState }: Record) => { - if (!searchParams) return null +interface HandleResponseParams { + issuerURL: string + clientID: string + oidcState: OidcStateData +} + +const handleResponse = async ({ issuerURL, clientID, oidcState }: HandleResponseParams): Promise => { + if (!searchParams) throw new Error("no search params available") const code = searchParams.get("code") const error = searchParams.get("error") @@ -108,7 +119,6 @@ const handleResponse = async ({ issuerURL, clientID, oidcState }: Record) => { +interface RefreshTokenParams { + issuerURL: string + clientID: string + refreshToken: string +} + +const refreshToken = async ({ issuerURL, clientID, refreshToken }: RefreshTokenParams): Promise => { if (!issuerURL) throw new Error("issuerURL is required") if (!clientID) throw new Error("clientID is required") const config = await getOidcConfig(issuerURL) - const body: any = { + const body: Record = { grant_type: "refresh_token", refresh_token: refreshToken, client_id: clientID, @@ -133,7 +149,7 @@ const refreshToken = async ({ issuerURL, clientID, refreshToken }: Record `${encodeURIComponent(k)}=${encodeURIComponent(body[k])}`) .join("&") - const data = await fetch(config.token_endpoint, { + const data: TokenResponse = await fetch(config.token_endpoint, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", @@ -141,7 +157,7 @@ const refreshToken = async ({ issuerURL, clientID, refreshToken }: Record r.json()) - if (data?.error) throw new Error(data.error) + if ("error" in data && data.error) throw new Error(typeof data.error === "string" ? data.error : "Token refresh failed") if (!data?.id_token) throw new Error("bad response, missing id_token") const tokenData = decodeIDToken(data.id_token) diff --git a/packages/oauth/src/implicitFlow.ts b/packages/oauth/src/implicitFlow.ts index ac67c56c02..391c35c2e2 100644 --- a/packages/oauth/src/implicitFlow.ts +++ b/packages/oauth/src/implicitFlow.ts @@ -3,12 +3,27 @@ * SPDX-License-Identifier: Apache-2.0 */ +import type { OidcStateData, FlowResponse } from "./types" import { decodeIDToken } from "./tokenHelpers" import { getOidcConfig } from "./oidcConfig" import { searchParams } from "./oidcState" import { paramsToUrl } from "./utils" -const buildRequestUrl = async ({ issuerURL, clientID, oidcState, callbackURL, params }: Record) => { +interface ImplicitFlowParams { + issuerURL: string + clientID: string + oidcState: OidcStateData + callbackURL?: string + params?: Record +} + +const buildRequestUrl = async ({ + issuerURL, + clientID, + oidcState, + callbackURL, + params, +}: ImplicitFlowParams): Promise => { const config = await getOidcConfig(issuerURL) const urlParams = paramsToUrl({ @@ -26,11 +41,10 @@ const buildRequestUrl = async ({ issuerURL, clientID, oidcState, callbackURL, pa /** * Handle the implicit flow response (id token flow) - * @param {object} params - * @returns {Promise} resolves to token data + * @returns Promise resolves to token data or null */ // eslint-disable-next-line @typescript-eslint/require-await -const handleResponse = async () => { +const handleResponse = async (): Promise => { if (!searchParams) return null const idToken = searchParams.get("id_token") diff --git a/packages/oauth/src/oidcState.ts b/packages/oauth/src/oidcState.ts index 0cac8fc405..2d052911a1 100644 --- a/packages/oauth/src/oidcState.ts +++ b/packages/oauth/src/oidcState.ts @@ -3,6 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import type { OidcStateData } from "./types" import { encodeBase64Json, decodeBase64Json, randomString } from "./utils" // @ts-ignore - oauth-pkce is a CommonJS module import getPkceImport from "oauth-pkce" @@ -10,18 +11,17 @@ import getPkceImport from "oauth-pkce" // Handle both ESM and CJS imports - Vite 8 changed CommonJS interop const getPkce = typeof getPkceImport === "function" ? getPkceImport : (getPkceImport as any)?.default || getPkceImport -type OidcState = { - key: string -} & Record +// PKCE callback type from oauth-pkce library +type PkceCallback = (_error: Error | null, _result: { verifier: string; challenge: string }) => void let lastStateKey: string // check if search or hash contains the state param and if there // is a saved state for this key. If there is a state in the store // for the state param, then this page load is an oidc response -let state: OidcState | null -export let searchParams: any -export const setSearchParams = (paramsValue: any) => { +let state: OidcStateData | null +export let searchParams: URLSearchParams | null +export const setSearchParams = (paramsValue: URLSearchParams | null) => { searchParams = paramsValue } // check search query string @@ -29,31 +29,36 @@ export const setSearchParams = (paramsValue: any) => { searchParams = new URLSearchParams(window.location.search) let stateString: string | null = null -if (searchParams.get("state")) { - stateString = window.sessionStorage.getItem(searchParams.get("state")) +const stateParam = searchParams.get("state") +if (stateParam) { + stateString = window.sessionStorage.getItem(stateParam) } if (!stateString) { // check hash query string searchParams = new URLSearchParams(window.location.hash?.replace(/^#(.*)/, "$1")) - if (searchParams.get("state")) { - stateString = window.sessionStorage.getItem(searchParams.get("state")) + const hashStateParam = searchParams.get("state") + if (hashStateParam) { + stateString = window.sessionStorage.getItem(hashStateParam) } } if (stateString) { // return if state exists // decode catches parse errors and returns null - state = decodeBase64Json(stateString) as OidcState | null + state = decodeBase64Json(stateString) as OidcStateData | null window.sessionStorage.removeItem(state!.key) } export const hasValidState = (): boolean => !!state -export const getState = (): OidcState | null => state +export const getState = (): OidcStateData | null => state -export const createState = async (props: any = {}, options: any): Promise => { +export const createState = async ( + props: Partial = {}, + options?: { pkce?: boolean } +): Promise => { window.sessionStorage.removeItem(lastStateKey) - const state: OidcState = { + const state: OidcStateData = { key: randomString(), nonce: randomString(), lastUrl: window.location.href, @@ -61,11 +66,12 @@ export const createState = async (props: any = {}, options: any): Promise = } if (options?.pkce) { - const { verifier, challenge }: any = await new Promise((resolve, reject) => { - getPkce(43, (error: any, { verifier, challenge }: { verifier: any; challenge: any }) => { + const { verifier, challenge } = await new Promise<{ verifier: string; challenge: string }>((resolve, reject) => { + const callback: PkceCallback = (error, result) => { if (error) reject(error instanceof Error ? error : new Error(String(error))) - else resolve({ verifier, challenge }) - }) + else resolve(result) + } + getPkce(43, callback) }) state.verifier = verifier From 6cf147f81d3811a6f53662263d7b7db1d7f7e084 Mon Sep 17 00:00:00 2001 From: Arturo Reuschenbach Puncernau Date: Tue, 29 Sep 2026 14:17:28 +0200 Subject: [PATCH 06/16] feat(oauth): prettier Signed-off-by: Arturo Reuschenbach Puncernau --- packages/oauth/src/codeFlow.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/oauth/src/codeFlow.ts b/packages/oauth/src/codeFlow.ts index ab36232512..61bf9a04e5 100644 --- a/packages/oauth/src/codeFlow.ts +++ b/packages/oauth/src/codeFlow.ts @@ -157,7 +157,8 @@ const refreshToken = async ({ issuerURL, clientID, refreshToken }: RefreshTokenP body: formBody, }).then((r) => r.json()) - if ("error" in data && data.error) throw new Error(typeof data.error === "string" ? data.error : "Token refresh failed") + if ("error" in data && data.error) + throw new Error(typeof data.error === "string" ? data.error : "Token refresh failed") if (!data?.id_token) throw new Error("bad response, missing id_token") const tokenData = decodeIDToken(data.id_token) From 60bec8d0f11803cebc8bbb4a8c47a89b3c3afc27 Mon Sep 17 00:00:00 2001 From: Arturo Reuschenbach Puncernau Date: Tue, 29 Sep 2026 15:24:57 +0200 Subject: [PATCH 07/16] feat(oauth): add TypeScript types to session handlers and improve type safety Signed-off-by: Arturo Reuschenbach Puncernau --- .../oauth/__tests__/mockedSession.test.ts | 2 + packages/oauth/__tests__/oidcSession.test.ts | 5 + packages/oauth/src/index.ts | 1 + packages/oauth/src/mockedSession.ts | 32 ++-- packages/oauth/src/oidcSession.ts | 150 +++++++++++++----- packages/oauth/src/tokenSession.ts | 34 ++-- packages/oauth/src/types.ts | 23 ++- 7 files changed, 171 insertions(+), 76 deletions(-) diff --git a/packages/oauth/__tests__/mockedSession.test.ts b/packages/oauth/__tests__/mockedSession.test.ts index 8ab322ff92..5436c1ed35 100644 --- a/packages/oauth/__tests__/mockedSession.test.ts +++ b/packages/oauth/__tests__/mockedSession.test.ts @@ -12,11 +12,13 @@ describe("mockedSession", () => { // onUpdate, test("throw error on missing onUpdate callback", () => { expect(() => { + // @ts-expect-error - Testing missing required parameter mockedSession() }).toThrow() }) test("warn if unknown options", () => { + // @ts-expect-error - Testing unknown options mockedSession({ onUpdate: () => null, test: "test", test2: "test" }) expect(globalThis.console.warn).toHaveBeenLastCalledWith( diff --git a/packages/oauth/__tests__/oidcSession.test.ts b/packages/oauth/__tests__/oidcSession.test.ts index 3eb54b118d..254d2bc916 100644 --- a/packages/oauth/__tests__/oidcSession.test.ts +++ b/packages/oauth/__tests__/oidcSession.test.ts @@ -19,6 +19,7 @@ describe("oidcSession", () => { issuerURL: "http://dummy.com", refresh: true, requestParams: { organization: "Test" }, + // @ts-expect-error - Testing unknown options unknown: true, test: "test", }) @@ -32,6 +33,7 @@ describe("oidcSession", () => { oidcSession({ clientID: "test", issuerURL: "http://dummy.com", + // @ts-expect-error - Testing invalid type onUpdate: true, }) }).toThrow("(OAUTH) onUpdate should be a function") @@ -39,12 +41,14 @@ describe("oidcSession", () => { test("issuerURL is required", () => { expect(() => { + // @ts-expect-error - Testing missing required parameter oidcSession({ clientID: "test" }) }).toThrow() }) test("clientID is required", () => { expect(() => { + // @ts-expect-error - Testing missing required parameter oidcSession({ issuerURL: "http://dummy.com" }) }).toThrow() }) @@ -58,6 +62,7 @@ describe("oidcSession", () => { oidcSession({ issuerURL: "http://dummy.com", clientID: "test", + // @ts-expect-error - Testing invalid flowType flowType: "something", }) }).toThrow("(OAUTH) flowType something is not supported!") diff --git a/packages/oauth/src/index.ts b/packages/oauth/src/index.ts index cb56aa2b78..3c1243c023 100644 --- a/packages/oauth/src/index.ts +++ b/packages/oauth/src/index.ts @@ -19,6 +19,7 @@ export type { OidcConfig, OidcSessionParams, OidcSessionInstance, + TokenSessionState, TokenSessionParams, TokenSessionInstance, MockedSessionParams, diff --git a/packages/oauth/src/mockedSession.ts b/packages/oauth/src/mockedSession.ts index 2d229924d1..1b6d5f21aa 100644 --- a/packages/oauth/src/mockedSession.ts +++ b/packages/oauth/src/mockedSession.ts @@ -3,9 +3,11 @@ * SPDX-License-Identifier: Apache-2.0 */ +import type { SessionState, MockedSessionParams, MockedSessionInstance, IdTokenData } from "./types" import { parseIdTokenData } from "./tokenHelpers" +import { decodeBase64Json } from "./utils" -const DEFAULT_MOCKED_TOKEN = { +const DEFAULT_MOCKED_TOKEN: IdTokenData = { iss: "https://auth.mock", sub: "3ksXP1FQq7j9125Q6ayY", aud: "mock-dev-env", @@ -19,31 +21,35 @@ const DEFAULT_MOCKED_TOKEN = { preferred_username: "Jane Doe", } -export const mockedAuthData = (tokenData: any = undefined) => { - try { - if (typeof tokenData === "string") { - tokenData = JSON.parse(atob(tokenData)) +export const mockedAuthData = (tokenData?: string | IdTokenData) => { + let parsedTokenData: Partial = {} + + if (typeof tokenData === "string") { + try { + parsedTokenData = decodeBase64Json(tokenData) as IdTokenData + } catch (_) { + console.warn("WARNING: (OAUTH MOCK) Could not parse token data") + parsedTokenData = {} } - } catch (_) { - console.warn(`WARNING: (OAUTH MOCK) Could not parse token data: ${tokenData}`) - tokenData = {} + } else if (tokenData && typeof tokenData === "object") { + parsedTokenData = tokenData } - const token = { + const token: IdTokenData = { ...DEFAULT_MOCKED_TOKEN, exp: Math.floor(Date.now() / 1000) + 8 * 3600, iat: Math.floor(Date.now() / 1000), - ...tokenData, + ...parsedTokenData, } return { - JWT: btoa(token), + JWT: btoa(JSON.stringify(token)), raw: token, refreshToken: "MOCK", parsed: parseIdTokenData(token), } } -export default function mockedSession(params: any = {}): any { +export default function mockedSession(params: MockedSessionParams): MockedSessionInstance { const { token, initialLogin, onUpdate, ...unknownProps } = params || {} if (typeof onUpdate !== "function") { @@ -59,7 +65,7 @@ export default function mockedSession(params: any = {}): any { } let authData = mockedAuthData(token) - let state: Record = { auth: null, error: null, loggedIn: false, isProcessing: false } + let state: SessionState = { auth: null, error: null, loggedIn: false, isProcessing: false } const login = () => { state = { auth: authData, error: null, loggedIn: true, isProcessing: false } diff --git a/packages/oauth/src/oidcSession.ts b/packages/oauth/src/oidcSession.ts index e1168f3473..ae4ecf0924 100644 --- a/packages/oauth/src/oidcSession.ts +++ b/packages/oauth/src/oidcSession.ts @@ -3,6 +3,16 @@ * SPDX-License-Identifier: Apache-2.0 */ +import type { + FlowType, + AuthData, + SessionState, + OidcSessionParams, + OidcSessionInstance, + OidcConfig, + OidcStateData, + IdTokenData, +} from "./types" import { parseIdTokenData } from "./tokenHelpers" import * as implicitFlowHandler from "./implicitFlow" import * as codeFlowHandler from "./codeFlow" @@ -13,18 +23,41 @@ import { OAuthError } from "./OAuthError" // define flow types export const FLOW_TYPE = { - IMPLICIT: "implicit", - CODE: "code", + IMPLICIT: "implicit" as const, + CODE: "code" as const, } // the state is determined when the lib is loaded const isOidcResponse = hasValidState() +// Flow handler interface +interface FlowHandler { + buildRequestUrl: (_params: { + issuerURL: string + clientID: string + oidcState: OidcStateData + callbackURL?: string + params?: Record + }) => Promise + handleResponse: (_params: { issuerURL: string; clientID: string; oidcState: OidcStateData }) => Promise<{ + tokenData: IdTokenData + idToken: string + refreshToken?: string | null | undefined + } | null> +} + // returns the correct flow handler -const oidcFlowHandler = (flowType: any) => { +const oidcFlowHandler = (flowType: FlowType): FlowHandler => { if (flowType === FLOW_TYPE.IMPLICIT) return implicitFlowHandler else if (flowType === FLOW_TYPE.CODE) return codeFlowHandler - throw new Error("no flow handler for " + flowType) + throw new Error("no flow handler for " + String(flowType)) +} + +interface CreateOidcRequestParams { + issuerURL: string + clientID: string + flowType: FlowType + requestParams?: string | Record } //############################## REQUEST ################################# @@ -34,7 +67,7 @@ const createOidcRequest = async ({ clientID, flowType, requestParams, -}: Record): Promise => { +}: CreateOidcRequestParams): Promise => { try { // create state props and store them in the SessionStorage // to use them after the redirect back from the ID provider @@ -53,7 +86,7 @@ const createOidcRequest = async ({ if (requestParams) { const params = typeof requestParams === "string" ? JSON.parse(requestParams) : requestParams const newUrl = new URL(url) - Object.keys(params).forEach((k) => newUrl.searchParams.append(k, params[k])) + Object.keys(params).forEach((k) => newUrl.searchParams.append(k, String(params[k]))) url = newUrl.href } @@ -69,9 +102,14 @@ const createOidcRequest = async ({ } } +interface HandleOidcResponseParams { + issuerURL: string + clientID: string +} + //################################ RESPONSE ################################# // handle the response from ID provider -const handleOidcResponse = async ({ issuerURL, clientID }: Record): Promise => { +const handleOidcResponse = async ({ issuerURL, clientID }: HandleOidcResponseParams): Promise => { const oidcState = getResponseState() // no oidc state presented or it does not match the stored one -> return null if (!oidcState) { @@ -80,19 +118,28 @@ const handleOidcResponse = async ({ issuerURL, clientID }: Record): } try { - const handler = oidcFlowHandler(oidcState.flowType) - const { tokenData, idToken, refreshToken }: any = await handler.handleResponse({ + const handler = oidcFlowHandler(oidcState.flowType as FlowType) + const response = await handler.handleResponse({ issuerURL, clientID, oidcState, }) + // implicitFlow can return null if searchParams is not available + // This shouldn't happen here since we validated state, but we handle it for type safety + if (!response) { + console.warn("(OAUTH) No response from flow handler") + return null + } + + const { tokenData, idToken, refreshToken } = response + if (oidcState.nonce && tokenData?.nonce !== oidcState.nonce) throw new Error("compromised id token content") - const authData = { + const authData: AuthData = { JWT: idToken, raw: tokenData, - refreshToken, + refreshToken: refreshToken || undefined, parsed: parseIdTokenData(tokenData), } @@ -111,8 +158,20 @@ const handleOidcResponse = async ({ issuerURL, clientID }: Record): } } +interface RefreshOidcTokenParams { + issuerURL: string + clientID: string + flowType: FlowType + refreshToken: string +} + // Refresh token works only for the code flow! -const refreshOidcToken = async ({ issuerURL, clientID, flowType, refreshToken }: Record): Promise => { +const refreshOidcToken = async ({ + issuerURL, + clientID, + flowType, + refreshToken, +}: RefreshOidcTokenParams): Promise => { if (flowType !== FLOW_TYPE.CODE) return null try { const { @@ -128,7 +187,7 @@ const refreshOidcToken = async ({ issuerURL, clientID, flowType, refreshToken }: return { JWT: idToken, raw: tokenData, - refreshToken: newRefreshToken, + refreshToken: newRefreshToken || undefined, parsed: parseIdTokenData(tokenData), } } catch (error: unknown) { @@ -141,12 +200,17 @@ const refreshOidcToken = async ({ issuerURL, clientID, flowType, refreshToken }: } } +interface OidcLogoutParams { + issuerURL: string + silent?: boolean +} + // This function removes cached token from storage. // We use iframe for ending oidc session in id provider // if we don't want user to leave current page -function oidcLogout({ issuerURL, silent }: any): void { +function oidcLogout({ issuerURL, silent }: OidcLogoutParams): void { getOidcConfig(issuerURL).then( - (config: any) => { + (config: OidcConfig) => { if (!config.end_session_endpoint) { console.warn( 'WARNING: (OAUTH) Id provider does not offer an endpoint for logout. Checked: "end_session_endpoint"' @@ -173,11 +237,11 @@ function oidcLogout({ issuerURL, silent }: any): void { } /** - * - * @param {object} params - * @returns {object} contains login, logout, refresh, currentState + * Create an OIDC session for authentication + * @param params - Session configuration parameters + * @returns Session instance with login, logout, refresh methods */ -const oidcSession = (params: any): any => { +const oidcSession = (params: OidcSessionParams): OidcSessionInstance => { const { issuerURL, clientID, initialLogin, refresh, onUpdate, requestParams, _callbackURL, ...unknownProps } = params || {} let { flowType } = params || {} @@ -202,16 +266,9 @@ const oidcSession = (params: any): any => { ) } - interface OidcState { - auth: any - error: any - isProcessing: boolean - loggedIn: any - } - // initialize state // this state is updated on every change on the auth status - let state: OidcState = { auth: null, error: null, isProcessing: false, loggedIn: false } + let state: SessionState = { auth: null, error: null, isProcessing: false, loggedIn: false } let refreshTimer: NodeJS.Timeout // this function re-creates a refresh timer if a refreshToken is presented @@ -219,7 +276,9 @@ const oidcSession = (params: any): any => { // clear refresh timer every time the auth date gets updated clearTimeout(refreshTimer) - if (!state?.auth?.refreshToken) return + // TypeScript discriminated union requires checking loggedIn to narrow the type. + // When loggedIn is true, TypeScript knows state.auth is AuthData (not null). + if (!state.loggedIn || !state.auth?.refreshToken) return const expiresAt = state.auth?.parsed?.expiresAt @@ -234,7 +293,8 @@ const oidcSession = (params: any): any => { let expirationTimer: NodeJS.Timeout const updateExpirationTimer = () => { clearTimeout(expirationTimer) - const expiresAt = state.auth?.parsed?.expiresAt + if (!state.loggedIn) return + const expiresAt = state.auth.parsed?.expiresAt if (expiresAt) { const expiresIn = expiresAt - Date.now() - 5000 console.info("(OAUTH) logout token in", Math.floor(expiresIn / 1000), "seconds") @@ -243,19 +303,36 @@ const oidcSession = (params: any): any => { } // define update method which updates the state and calls the callback function - const update = (newState: any) => { + // Define a type-safe update parameter that enforces the discriminated union + type SessionStateUpdate = + // Update to logged-in state (must have auth) + | (Partial<{ error: string | null; isProcessing: boolean }> & { loggedIn: true; auth: AuthData }) + // Update to logged-out state (must have auth: null) + | (Partial<{ error: string | null; isProcessing: boolean }> & { loggedIn: false; auth: null }) + // Update only error/isProcessing without touching loggedIn/auth + | Partial> + + const update = (newState: SessionStateUpdate) => { state = { ...state, ...newState } if (onUpdate) onUpdate({ ...state }) - if (refresh && state?.auth?.refreshToken) updateRefresher() - else if (state?.auth?.parsed?.expiresAt) updateExpirationTimer() + // TypeScript discriminated union requires checking loggedIn to narrow the type. + // When loggedIn is true, TypeScript knows state.auth is AuthData (not null). + if (refresh && state.loggedIn && state.auth?.refreshToken) updateRefresher() + else if (state.loggedIn && state.auth?.parsed?.expiresAt) updateExpirationTimer() } // handle new data from odic response - const receiveNewData = async (promise: Promise) => { + const receiveNewData = async (promise: Promise) => { try { const data = await promise - update({ auth: data, error: null, loggedIn: !!data, isProcessing: false }) + if (data) { + // Logged in with auth data + update({ auth: data, error: null, loggedIn: true, isProcessing: false }) + } else { + // No auth data received + update({ auth: null, error: null, loggedIn: false, isProcessing: false }) + } } catch (error: unknown) { update({ auth: null, @@ -269,7 +346,8 @@ const oidcSession = (params: any): any => { // refresh function const refreshAuth = () => { if (!refresh) return - const refreshToken = state?.auth?.refreshToken + if (!state.loggedIn) return + const refreshToken = state.auth.refreshToken if (refreshToken) { console.info("(OAUTH) refresh token now") const promise = refreshOidcToken({ @@ -293,7 +371,7 @@ const oidcSession = (params: any): any => { ) } - const logout = (options: any) => { + const logout = (options?: { resetOIDCSession?: boolean; silent?: boolean }) => { console.info("(OAUTH) logout") update({ auth: null, error: null, loggedIn: false, isProcessing: false }) if (options?.resetOIDCSession) oidcLogout({ issuerURL, silent: options?.silent === true }) diff --git a/packages/oauth/src/tokenSession.ts b/packages/oauth/src/tokenSession.ts index 9d0a9eceb3..61bc1fc6ec 100644 --- a/packages/oauth/src/tokenSession.ts +++ b/packages/oauth/src/tokenSession.ts @@ -3,7 +3,12 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { IdTokenData, ParsedTokenData } from "./types" +import type { + IdTokenData, + ParsedTokenData, + TokenSessionParams, + TokenSessionInstance, +} from "./types" import { parseIdTokenData, decodeIDToken } from "./tokenHelpers" interface AuthData { @@ -13,7 +18,10 @@ interface AuthData { parsed: ReturnType } -export const composeAuthData = (token: string, options: any): { authData: AuthData | null; error: Error | null } => { +export const composeAuthData = ( + token: string, + options?: Record +): { authData: AuthData | null; error: Error | null } => { let tokenData: IdTokenData try { @@ -48,26 +56,6 @@ export const composeAuthData = (token: string, options: any): { authData: AuthDa } } -interface TokenSessionState { - auth: AuthData | null - error: unknown - loggedIn: boolean - isProcessing: boolean -} - -interface TokenSessionReturnType { - login: () => void - logout: () => void - currentState: () => TokenSessionState -} - -interface TokenSessionParams { - token: string - options?: Record - initialLogin?: boolean - onUpdate: (_state: TokenSessionState) => void -} - /** * Creates a token-based session where the provided token is validated, parsed, * and extended with the specified options. The resulting authentication state @@ -99,7 +87,7 @@ interface TokenSessionParams { * }, * }); */ -export default function tokenSession(params: TokenSessionParams): TokenSessionReturnType { +export default function tokenSession(params: TokenSessionParams): TokenSessionInstance { const { token, onUpdate, initialLogin, options } = params || {} const { authData, error } = composeAuthData(token, options) diff --git a/packages/oauth/src/types.ts b/packages/oauth/src/types.ts index 8bf3150cb1..0f0adc4997 100644 --- a/packages/oauth/src/types.ts +++ b/packages/oauth/src/types.ts @@ -284,6 +284,21 @@ export interface OidcSessionInstance { currentState: () => SessionState } +/** + * Token session state (simpler than SessionState, no discriminated union) + */ +export interface TokenSessionState { + auth: { + JWT: string + raw: Record + refreshToken: string + parsed: ParsedTokenData + } | null + error: unknown + loggedIn: boolean + isProcessing: boolean +} + /** * Token session configuration parameters */ @@ -295,7 +310,7 @@ export interface TokenSessionParams { /** Whether to trigger login immediately */ initialLogin?: boolean /** Callback function called on auth state updates */ - onUpdate: (_state: SessionState) => void + onUpdate: (_state: TokenSessionState) => void } /** @@ -307,15 +322,15 @@ export interface TokenSessionInstance { /** Trigger logout */ logout: () => void /** Get current session state */ - currentState: () => SessionState + currentState: () => TokenSessionState } /** * Mocked session configuration parameters */ export interface MockedSessionParams { - /** Mock token data (string or object) */ - token?: string | Record + /** Mock token data (base64 string or IdTokenData object) */ + token?: string | IdTokenData /** Whether to trigger login immediately */ initialLogin?: boolean /** Callback function called on auth state updates */ From 988dfe8f0baeccf759973ac1c90678953d909f93 Mon Sep 17 00:00:00 2001 From: Arturo Reuschenbach Puncernau Date: Tue, 29 Sep 2026 15:29:42 +0200 Subject: [PATCH 08/16] feat(oauth): prettier Signed-off-by: Arturo Reuschenbach Puncernau --- packages/oauth/src/oidcSession.ts | 2 +- packages/oauth/src/tokenSession.ts | 7 +------ 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/packages/oauth/src/oidcSession.ts b/packages/oauth/src/oidcSession.ts index ae4ecf0924..500fc47fb5 100644 --- a/packages/oauth/src/oidcSession.ts +++ b/packages/oauth/src/oidcSession.ts @@ -310,7 +310,7 @@ const oidcSession = (params: OidcSessionParams): OidcSessionInstance => { // Update to logged-out state (must have auth: null) | (Partial<{ error: string | null; isProcessing: boolean }> & { loggedIn: false; auth: null }) // Update only error/isProcessing without touching loggedIn/auth - | Partial> + | Partial> const update = (newState: SessionStateUpdate) => { state = { ...state, ...newState } diff --git a/packages/oauth/src/tokenSession.ts b/packages/oauth/src/tokenSession.ts index 61bc1fc6ec..d31e333246 100644 --- a/packages/oauth/src/tokenSession.ts +++ b/packages/oauth/src/tokenSession.ts @@ -3,12 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { - IdTokenData, - ParsedTokenData, - TokenSessionParams, - TokenSessionInstance, -} from "./types" +import type { IdTokenData, ParsedTokenData, TokenSessionParams, TokenSessionInstance } from "./types" import { parseIdTokenData, decodeIDToken } from "./tokenHelpers" interface AuthData { From 8856c844f16a21c3a96c88f968107b95c6ce8344 Mon Sep 17 00:00:00 2001 From: Arturo Reuschenbach Puncernau Date: Tue, 29 Sep 2026 15:40:26 +0200 Subject: [PATCH 09/16] feat(oauth): implement ai suggestion Signed-off-by: Arturo Reuschenbach Puncernau --- packages/oauth/src/oidcState.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/oauth/src/oidcState.ts b/packages/oauth/src/oidcState.ts index 2d052911a1..d727320708 100644 --- a/packages/oauth/src/oidcState.ts +++ b/packages/oauth/src/oidcState.ts @@ -46,8 +46,20 @@ if (!stateString) { if (stateString) { // return if state exists // decode catches parse errors and returns null - state = decodeBase64Json(stateString) as OidcStateData | null - window.sessionStorage.removeItem(state!.key) + const decodedState = decodeBase64Json(stateString) + if ( + decodedState && + typeof decodedState === "object" && + "key" in decodedState && + typeof decodedState.key === "string" && + "nonce" in decodedState && + typeof decodedState.nonce === "string" + ) { + state = decodedState as OidcStateData + window.sessionStorage.removeItem(state.key) + } else { + state = null + } } export const hasValidState = (): boolean => !!state From f908b6c1f1ba03cb58aa4778c8700454a87b4f46 Mon Sep 17 00:00:00 2001 From: Arturo Reuschenbach Puncernau Date: Tue, 29 Sep 2026 15:45:14 +0200 Subject: [PATCH 10/16] feat(oauth): implement ai suggestion Signed-off-by: Arturo Reuschenbach Puncernau --- packages/oauth/src/tokenHelpers.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/oauth/src/tokenHelpers.ts b/packages/oauth/src/tokenHelpers.ts index 03d89dfab2..6fbe53d3bf 100644 --- a/packages/oauth/src/tokenHelpers.ts +++ b/packages/oauth/src/tokenHelpers.ts @@ -13,7 +13,14 @@ import { decodeBase64Json } from "./utils" */ export function decodeIDToken(idToken: string): IdTokenData { const [_head, tokenData, _signature] = idToken.split(".") - return decodeBase64Json(tokenData) as IdTokenData + const decoded = decodeBase64Json(tokenData) + + // Validate decoded payload is an object before returning + if (!decoded || typeof decoded !== "object") { + throw new Error("bad format of id_token") + } + + return decoded as IdTokenData } const capitalize = (str: string): string => { From 5b12d6baba45c1ef8379c45aab70de0ce069eacf Mon Sep 17 00:00:00 2001 From: Arturo Reuschenbach Puncernau Date: Tue, 29 Sep 2026 15:48:13 +0200 Subject: [PATCH 11/16] feat(oauth): implement ai suggestion Signed-off-by: Arturo Reuschenbach Puncernau --- packages/oauth/src/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/oauth/src/types.ts b/packages/oauth/src/types.ts index 0f0adc4997..941463b95c 100644 --- a/packages/oauth/src/types.ts +++ b/packages/oauth/src/types.ts @@ -133,7 +133,7 @@ export interface AuthData { /** Raw token data (decoded JWT payload) */ raw: IdTokenData /** Refresh token (if available) */ - refreshToken?: string + refreshToken?: string | null /** Parsed and processed token data */ parsed: ParsedTokenData } From 76f8cf0c32d797a87b768bab10b7bacaf34ca8bd Mon Sep 17 00:00:00 2001 From: Arturo Reuschenbach Puncernau Date: Tue, 29 Sep 2026 15:51:52 +0200 Subject: [PATCH 12/16] feat(oauth): implement ai suggestion Signed-off-by: Arturo Reuschenbach Puncernau --- packages/oauth/src/oidcSession.ts | 7 +++++-- packages/oauth/src/types.ts | 4 ++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/oauth/src/oidcSession.ts b/packages/oauth/src/oidcSession.ts index 500fc47fb5..7b76bf6a77 100644 --- a/packages/oauth/src/oidcSession.ts +++ b/packages/oauth/src/oidcSession.ts @@ -58,6 +58,7 @@ interface CreateOidcRequestParams { clientID: string flowType: FlowType requestParams?: string | Record + callbackURL?: string } //############################## REQUEST ################################# @@ -67,6 +68,7 @@ const createOidcRequest = async ({ clientID, flowType, requestParams, + callbackURL, }: CreateOidcRequestParams): Promise => { try { // create state props and store them in the SessionStorage @@ -80,6 +82,7 @@ const createOidcRequest = async ({ issuerURL, clientID, oidcState, + callbackURL, }) // add additional search params @@ -242,7 +245,7 @@ function oidcLogout({ issuerURL, silent }: OidcLogoutParams): void { * @returns Session instance with login, logout, refresh methods */ const oidcSession = (params: OidcSessionParams): OidcSessionInstance => { - const { issuerURL, clientID, initialLogin, refresh, onUpdate, requestParams, _callbackURL, ...unknownProps } = + const { issuerURL, clientID, initialLogin, refresh, onUpdate, requestParams, callbackURL, ...unknownProps } = params || {} let { flowType } = params || {} if (!issuerURL || !clientID) { @@ -365,7 +368,7 @@ const oidcSession = (params: OidcSessionParams): OidcSessionInstance => { const login = () => { update({ isProcessing: true }) - createOidcRequest({ issuerURL, clientID, flowType, requestParams }).then( + createOidcRequest({ issuerURL, clientID, flowType, requestParams, callbackURL }).then( () => {}, () => {} ) diff --git a/packages/oauth/src/types.ts b/packages/oauth/src/types.ts index 941463b95c..07b5725ffa 100644 --- a/packages/oauth/src/types.ts +++ b/packages/oauth/src/types.ts @@ -266,8 +266,8 @@ export interface OidcSessionParams { onUpdate?: (_state: SessionState) => void /** Additional request parameters */ requestParams?: string | Record - /** Internal: callback URL override */ - _callbackURL?: string + /** Callback URL override for OAuth redirect */ + callbackURL?: string } /** From 7f33b160b0f57b0752520d38d2f4f1da5c8d38f5 Mon Sep 17 00:00:00 2001 From: Arturo Reuschenbach Puncernau Date: Tue, 29 Sep 2026 16:28:18 +0200 Subject: [PATCH 13/16] feat(oauth): implement ai suggestion Signed-off-by: Arturo Reuschenbach Puncernau --- packages/oauth/src/mockedSession.ts | 4 ++-- packages/oauth/src/utils.ts | 18 ++++++++++++++++-- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/packages/oauth/src/mockedSession.ts b/packages/oauth/src/mockedSession.ts index 1b6d5f21aa..c4fda630aa 100644 --- a/packages/oauth/src/mockedSession.ts +++ b/packages/oauth/src/mockedSession.ts @@ -5,7 +5,7 @@ import type { SessionState, MockedSessionParams, MockedSessionInstance, IdTokenData } from "./types" import { parseIdTokenData } from "./tokenHelpers" -import { decodeBase64Json } from "./utils" +import { decodeBase64Json, encodeBase64Json } from "./utils" const DEFAULT_MOCKED_TOKEN: IdTokenData = { iss: "https://auth.mock", @@ -42,7 +42,7 @@ export const mockedAuthData = (tokenData?: string | IdTokenData) => { ...parsedTokenData, } return { - JWT: btoa(JSON.stringify(token)), + JWT: encodeBase64Json(token), raw: token, refreshToken: "MOCK", parsed: parseIdTokenData(token), diff --git a/packages/oauth/src/utils.ts b/packages/oauth/src/utils.ts index 4a3b62e4d7..d1adcb6b09 100644 --- a/packages/oauth/src/utils.ts +++ b/packages/oauth/src/utils.ts @@ -22,19 +22,33 @@ export function randomString(length: number = 60): string { /** * Encode a value as base64 JSON string + * Handles unicode characters properly using TextEncoder * @param props - Value to encode * @returns base64 encoded json string */ -export const encodeBase64Json = (props: unknown): string => window.btoa(JSON.stringify(props)) +export const encodeBase64Json = (props: unknown): string => { + const jsonString = JSON.stringify(props) + // Use TextEncoder to handle unicode characters properly + const bytes = new TextEncoder().encode(jsonString) + // Convert bytes to binary string for btoa + const binaryString = Array.from(bytes, (byte) => String.fromCharCode(byte)).join("") + return window.btoa(binaryString) +} /** * Decode a base64 encoded JSON string + * Handles unicode characters properly using TextDecoder * @param string - base64 encoded json string * @returns Decoded value or null if decoding fails */ export const decodeBase64Json = (string: string): unknown => { try { - return JSON.parse(window.atob(string)) + const binaryString = window.atob(string) + // Convert binary string to bytes + const bytes = Uint8Array.from(binaryString, (char) => char.charCodeAt(0)) + // Use TextDecoder to handle unicode characters properly + const jsonString = new TextDecoder().decode(bytes) + return JSON.parse(jsonString) } catch (_) { return null } From 61013e4688d29f97fedadc8908ed2e29c3c54ef1 Mon Sep 17 00:00:00 2001 From: Arturo Reuschenbach Puncernau Date: Tue, 29 Sep 2026 16:36:41 +0200 Subject: [PATCH 14/16] feat(oauth): implement ai suggestion Signed-off-by: Arturo Reuschenbach Puncernau --- packages/oauth/src/codeFlow.ts | 8 +++++++- packages/oauth/src/oidcSession.ts | 11 +++++++++-- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/oauth/src/codeFlow.ts b/packages/oauth/src/codeFlow.ts index 61bf9a04e5..3cf986216a 100644 --- a/packages/oauth/src/codeFlow.ts +++ b/packages/oauth/src/codeFlow.ts @@ -14,6 +14,7 @@ interface ExchangeCodeParams { code: string verifier?: string clientID: string + callbackURL?: string } export const exchangeCode = async ({ @@ -21,13 +22,14 @@ export const exchangeCode = async ({ code, verifier, clientID, + callbackURL, }: ExchangeCodeParams): Promise => { if (!clientID) throw new Error("clientID is required") const body: Record = { grant_type: "authorization_code", code, - redirect_uri: window.location.origin, + redirect_uri: callbackURL || window.location.origin, client_id: clientID, } @@ -109,11 +111,15 @@ const handleResponse = async ({ issuerURL, clientID, oidcState }: HandleResponse const config = await getOidcConfig(issuerURL) if (!config) throw new Error("could not load oidc config, issuerURL: " + issuerURL) + // Retrieve callbackURL from the persisted OIDC state + const callbackURL = typeof oidcState.callbackURL === "string" ? oidcState.callbackURL : undefined + const data = await exchangeCode({ tokenEndpoint: config.token_endpoint, code, verifier: oidcState.verifier, clientID, + callbackURL, }) if (!data?.id_token || typeof data.id_token !== "string") throw new Error("bad response, missing id_token") diff --git a/packages/oauth/src/oidcSession.ts b/packages/oauth/src/oidcSession.ts index 7b76bf6a77..c3246aa83e 100644 --- a/packages/oauth/src/oidcSession.ts +++ b/packages/oauth/src/oidcSession.ts @@ -39,7 +39,11 @@ interface FlowHandler { callbackURL?: string params?: Record }) => Promise - handleResponse: (_params: { issuerURL: string; clientID: string; oidcState: OidcStateData }) => Promise<{ + handleResponse: (_params: { + issuerURL: string + clientID: string + oidcState: OidcStateData + }) => Promise<{ tokenData: IdTokenData idToken: string refreshToken?: string | null | undefined @@ -74,7 +78,10 @@ const createOidcRequest = async ({ // create state props and store them in the SessionStorage // to use them after the redirect back from the ID provider // for code flow we use pkce and without secret! - const oidcState = await createRequestState({ flowType }, { pkce: flowType === FLOW_TYPE.CODE }) + const oidcState = await createRequestState( + { flowType, callbackURL }, + { pkce: flowType === FLOW_TYPE.CODE } + ) const handler = oidcFlowHandler(flowType) // make the actual request From f2326699e6634cd78f0745b2b0df49ed408b3774 Mon Sep 17 00:00:00 2001 From: Arturo Reuschenbach Puncernau Date: Tue, 29 Sep 2026 16:39:14 +0200 Subject: [PATCH 15/16] feat(oauth): implement ai suggestion Signed-off-by: Arturo Reuschenbach Puncernau --- packages/oauth/src/mockedSession.ts | 6 +++++- packages/oauth/src/oidcSession.ts | 11 ++--------- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/packages/oauth/src/mockedSession.ts b/packages/oauth/src/mockedSession.ts index c4fda630aa..e0d3dcec47 100644 --- a/packages/oauth/src/mockedSession.ts +++ b/packages/oauth/src/mockedSession.ts @@ -26,7 +26,11 @@ export const mockedAuthData = (tokenData?: string | IdTokenData) => { if (typeof tokenData === "string") { try { - parsedTokenData = decodeBase64Json(tokenData) as IdTokenData + const decoded = decodeBase64Json(tokenData) + if (!decoded || typeof decoded !== "object" || Array.isArray(decoded)) { + throw new Error("Invalid mock token data") + } + parsedTokenData = decoded } catch (_) { console.warn("WARNING: (OAUTH MOCK) Could not parse token data") parsedTokenData = {} diff --git a/packages/oauth/src/oidcSession.ts b/packages/oauth/src/oidcSession.ts index c3246aa83e..275b564f36 100644 --- a/packages/oauth/src/oidcSession.ts +++ b/packages/oauth/src/oidcSession.ts @@ -39,11 +39,7 @@ interface FlowHandler { callbackURL?: string params?: Record }) => Promise - handleResponse: (_params: { - issuerURL: string - clientID: string - oidcState: OidcStateData - }) => Promise<{ + handleResponse: (_params: { issuerURL: string; clientID: string; oidcState: OidcStateData }) => Promise<{ tokenData: IdTokenData idToken: string refreshToken?: string | null | undefined @@ -78,10 +74,7 @@ const createOidcRequest = async ({ // create state props and store them in the SessionStorage // to use them after the redirect back from the ID provider // for code flow we use pkce and without secret! - const oidcState = await createRequestState( - { flowType, callbackURL }, - { pkce: flowType === FLOW_TYPE.CODE } - ) + const oidcState = await createRequestState({ flowType, callbackURL }, { pkce: flowType === FLOW_TYPE.CODE }) const handler = oidcFlowHandler(flowType) // make the actual request From f0e299c349daa3e8c1c0c4f972242a65ef1bcb23 Mon Sep 17 00:00:00 2001 From: Arturo Reuschenbach Puncernau Date: Tue, 29 Sep 2026 16:53:28 +0200 Subject: [PATCH 16/16] feat(oauth): implement ai suggestion Signed-off-by: Arturo Reuschenbach Puncernau --- packages/oauth/__tests__/codeFlow.test.ts | 24 +++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/packages/oauth/__tests__/codeFlow.test.ts b/packages/oauth/__tests__/codeFlow.test.ts index 659aa9f0f0..218783e0d4 100644 --- a/packages/oauth/__tests__/codeFlow.test.ts +++ b/packages/oauth/__tests__/codeFlow.test.ts @@ -201,6 +201,30 @@ describe("handleResponse", () => { ) }) + test("uses custom callbackURL in token exchange", async () => { + oidcState.setSearchParams(new URLSearchParams("code=12345678")) + + await handleResponse({ + issuerURL: "http://issuer.com", + clientID: "test", + oidcState: { + key: "test-key", + nonce: "test-nonce", + verifier: "12345", + callbackURL: "https://custom-callback.com", + }, + }) + + expect(globalThis.fetch).toHaveBeenCalledWith( + "https://issuer.com/token", + expect.objectContaining({ + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + method: "POST", + body: "grant_type=authorization_code&code=12345678&redirect_uri=https%3A%2F%2Fcustom-callback.com&client_id=test&code_verifier=12345", + }) + ) + }) + test("fetch returns token data", async () => { oidcState.setSearchParams(new URLSearchParams("code=123456789"))