diff --git a/packages/oauth/__tests__/codeFlow.test.ts b/packages/oauth/__tests__/codeFlow.test.ts index 2ef63114ee..218783e0d4 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", }, }) @@ -198,16 +201,40 @@ 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")) 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/__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/codeFlow.ts b/packages/oauth/src/codeFlow.ts index 690a9b5201..3cf986216a 100644 --- a/packages/oauth/src/codeFlow.ts +++ b/packages/oauth/src/codeFlow.ts @@ -3,26 +3,18 @@ * 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 verifier?: string clientID: string + callbackURL?: string } export const exchangeCode = async ({ @@ -30,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, } @@ -62,7 +55,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 +84,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 +92,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") @@ -98,17 +111,20 @@ 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 +155,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 +163,8 @@ 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/index.ts b/packages/oauth/src/index.ts index 29b02eb04f..3c1243c023 100644 --- a/packages/oauth/src/index.ts +++ b/packages/oauth/src/index.ts @@ -6,4 +6,24 @@ 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, + TokenSessionState, + TokenSessionParams, + TokenSessionInstance, + MockedSessionParams, + MockedSessionInstance, + TokenResponse, + OidcStateData, +} from "./types" diff --git a/packages/oauth/src/mockedSession.ts b/packages/oauth/src/mockedSession.ts index 2d229924d1..e0d3dcec47 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, encodeBase64Json } from "./utils" -const DEFAULT_MOCKED_TOKEN = { +const DEFAULT_MOCKED_TOKEN: IdTokenData = { iss: "https://auth.mock", sub: "3ksXP1FQq7j9125Q6ayY", aud: "mock-dev-env", @@ -19,31 +21,39 @@ 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 { + 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 = {} } - } 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: encodeBase64Json(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 +69,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/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/oidcSession.ts b/packages/oauth/src/oidcSession.ts index e1168f3473..275b564f36 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,42 @@ 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 + callbackURL?: string } //############################## REQUEST ################################# @@ -34,12 +68,13 @@ const createOidcRequest = async ({ clientID, flowType, requestParams, -}: Record): Promise => { + callbackURL, +}: CreateOidcRequestParams): Promise => { try { // 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 @@ -47,13 +82,14 @@ const createOidcRequest = async ({ issuerURL, clientID, oidcState, + callbackURL, }) // add additional search params 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 +105,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 +121,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 +161,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 +190,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 +203,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,12 +240,12 @@ 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 { issuerURL, clientID, initialLogin, refresh, onUpdate, requestParams, _callbackURL, ...unknownProps } = +const oidcSession = (params: OidcSessionParams): OidcSessionInstance => { + const { issuerURL, clientID, initialLogin, refresh, onUpdate, requestParams, callbackURL, ...unknownProps } = params || {} let { flowType } = params || {} if (!issuerURL || !clientID) { @@ -202,16 +269,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 +279,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 +296,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 +306,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 +349,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({ @@ -287,13 +368,13 @@ const oidcSession = (params: any): any => { const login = () => { update({ isProcessing: true }) - createOidcRequest({ issuerURL, clientID, flowType, requestParams }).then( + createOidcRequest({ issuerURL, clientID, flowType, requestParams, callbackURL }).then( () => {}, () => {} ) } - 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/oidcState.ts b/packages/oauth/src/oidcState.ts index 499fa39dcd..d727320708 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,48 @@ 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) - 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 -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 +78,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 diff --git a/packages/oauth/src/tokenHelpers.ts b/packages/oauth/src/tokenHelpers.ts index f672cc1b67..6fbe53d3bf 100644 --- a/packages/oauth/src/tokenHelpers.ts +++ b/packages/oauth/src/tokenHelpers.ts @@ -3,15 +3,24 @@ * 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) + 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 => { @@ -38,23 +47,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 +77,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 +95,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 +114,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..d31e333246 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, TokenSessionParams, TokenSessionInstance } from "./types" import { parseIdTokenData, decodeIDToken } from "./tokenHelpers" interface AuthData { @@ -12,9 +13,11 @@ interface AuthData { parsed: ReturnType } -export const composeAuthData = (token: string, options: any): { authData: AuthData | null; error: Error | null } => { - let tokenData: Record - let parsedData: unknown +export const composeAuthData = ( + token: string, + options?: Record +): { authData: AuthData | null; error: Error | null } => { + let tokenData: IdTokenData try { tokenData = decodeIDToken(token) @@ -27,6 +30,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) { @@ -47,26 +51,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 @@ -98,7 +82,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 new file mode 100644 index 0000000000..07b5725ffa --- /dev/null +++ b/packages/oauth/src/types.ts @@ -0,0 +1,352 @@ +/* + * 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 | null + /** 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 + /** Callback URL override for OAuth redirect */ + 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 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 + */ +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: TokenSessionState) => void +} + +/** + * Token session instance (return value) + */ +export interface TokenSessionInstance { + /** Trigger login */ + login: () => void + /** Trigger logout */ + logout: () => void + /** Get current session state */ + currentState: () => TokenSessionState +} + +/** + * Mocked session configuration parameters + */ +export interface MockedSessionParams { + /** 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 */ + 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 +} diff --git a/packages/oauth/src/utils.ts b/packages/oauth/src/utils.ts index 4d3ec039fd..d1adcb6b09 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,27 +21,46 @@ export function randomString(length: number = 60): string { } /** - * - * @param {object} props + * 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: any): 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) +} /** - * - * @param {string} string, base64 encoded json string - * @returns {object} json + * 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): any => { +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 } } -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() }