From 7915b91328f5189e3c4a003fe30cb4e8859a3b53 Mon Sep 17 00:00:00 2001 From: Arturo Reuschenbach Puncernau Date: Fri, 2 Oct 2026 15:38:05 +0200 Subject: [PATCH 1/7] chore(oauth): enable strict TypeScript ESLint rules Signed-off-by: Arturo Reuschenbach Puncernau --- .changeset/oauth-eslint-strict-types.md | 5 ++++ packages/oauth/__tests__/implicitFlow.test.ts | 7 +++--- .../oauth/__tests__/mockedSession.test.ts | 10 ++++---- packages/oauth/__tests__/oidcSession.test.ts | 3 ++- packages/oauth/__tests__/tokenSession.test.ts | 24 ++++++++++++------- packages/oauth/eslint.config.mjs | 17 ++----------- packages/oauth/src/codeFlow.ts | 6 ++--- packages/oauth/src/oidcConfig.ts | 3 ++- packages/oauth/src/oidcSession.ts | 3 ++- packages/oauth/src/oidcState.ts | 6 +++-- 10 files changed, 44 insertions(+), 40 deletions(-) create mode 100644 .changeset/oauth-eslint-strict-types.md diff --git a/.changeset/oauth-eslint-strict-types.md b/.changeset/oauth-eslint-strict-types.md new file mode 100644 index 0000000000..d098a951f5 --- /dev/null +++ b/.changeset/oauth-eslint-strict-types.md @@ -0,0 +1,5 @@ +--- +"@cloudoperators/juno-oauth": patch +--- + +chore(oauth): enable strict TypeScript ESLint rules diff --git a/packages/oauth/__tests__/implicitFlow.test.ts b/packages/oauth/__tests__/implicitFlow.test.ts index b2d4448f3d..255570716d 100644 --- a/packages/oauth/__tests__/implicitFlow.test.ts +++ b/packages/oauth/__tests__/implicitFlow.test.ts @@ -9,6 +9,7 @@ import config from "./__utils__/oidcConfigMock" import { testIdToken, testTokenData } from "./__utils__/idTokenMock" import { buildRequestUrl, handleResponse } from "../src/implicitFlow" +import type { FlowResponse } from "../src/types" import * as oidcState from "../src/oidcState" @@ -106,9 +107,9 @@ describe("handleResponse", () => { test("should return token data", async () => { oidcState.setSearchParams(new URLSearchParams("id_token=" + testIdToken)) - await handleResponse().then(({ tokenData, idToken }: any) => { - expect(tokenData).toEqual(expect.objectContaining(testTokenData)) - expect(idToken).toEqual(testIdToken) + await handleResponse().then((response: FlowResponse | null) => { + expect(response?.tokenData).toEqual(expect.objectContaining(testTokenData)) + expect(response?.idToken).toEqual(testIdToken) }) }) }) diff --git a/packages/oauth/__tests__/mockedSession.test.ts b/packages/oauth/__tests__/mockedSession.test.ts index 5436c1ed35..3d7100280d 100644 --- a/packages/oauth/__tests__/mockedSession.test.ts +++ b/packages/oauth/__tests__/mockedSession.test.ts @@ -105,7 +105,7 @@ describe("mockedSession", () => { session.logout() session.login() expect(onUpdate).toHaveBeenLastCalledWith({ - auth: expect.anything(), + auth: expect.anything() as unknown, error: null, loggedIn: true, isProcessing: false, @@ -140,10 +140,10 @@ describe("mockedSession", () => { email_verified: true, groups: ["test1", "test2"], name: "D123456", - }), - parsed: expect.anything(), - JWT: expect.anything(), - refreshToken: expect.anything(), + }) as unknown, + parsed: expect.anything() as unknown, + JWT: expect.anything() as unknown, + refreshToken: expect.anything() as unknown, }, loggedIn: true, error: null, diff --git a/packages/oauth/__tests__/oidcSession.test.ts b/packages/oauth/__tests__/oidcSession.test.ts index 254d2bc916..a5c4e89b6b 100644 --- a/packages/oauth/__tests__/oidcSession.test.ts +++ b/packages/oauth/__tests__/oidcSession.test.ts @@ -7,6 +7,7 @@ import { beforeEach, describe, expect, test } from "vitest" import "./__utils__/globalsMock" import oidcSession from "../src/oidcSession" +import type { OidcSessionInstance } from "../src/types" describe("oidcSession", () => { test("should be a function", () => { @@ -69,7 +70,7 @@ describe("oidcSession", () => { }) describe("returned result", () => { - let session: any = undefined + let session: OidcSessionInstance beforeEach(() => { session = oidcSession({ clientID: "test", issuerURL: "http://dummy.com" }) }) diff --git a/packages/oauth/__tests__/tokenSession.test.ts b/packages/oauth/__tests__/tokenSession.test.ts index 114b00319d..102d47c1ea 100644 --- a/packages/oauth/__tests__/tokenSession.test.ts +++ b/packages/oauth/__tests__/tokenSession.test.ts @@ -5,6 +5,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest" import tokenSession, { composeAuthData } from "../src/tokenSession" +import type { TokenSessionInstance, TokenSessionState } from "../src/types" import { testIdToken } from "./__utils__/idTokenMock" import * as tokenHelpers from "../src/tokenHelpers" @@ -52,7 +53,7 @@ describe("composeAuthData", () => { }) describe("tokenSession", () => { - let onUpdateMock: any + let onUpdateMock: (_state: TokenSessionState) => void beforeEach(() => { onUpdateMock = vi.fn() }) @@ -63,7 +64,7 @@ describe("tokenSession", () => { }) it("should initialize the session with correct default state", () => { - const session = tokenSession({ token: testIdToken, onUpdate: onUpdateMock }) + const session: TokenSessionInstance = tokenSession({ token: testIdToken, onUpdate: onUpdateMock }) expect(session.currentState()).toEqual({ auth: null, @@ -76,15 +77,20 @@ describe("tokenSession", () => { it("should initialize if initialLogin boolean set", () => { const options = { anySpecialAttribute: "miau" } - const session = tokenSession({ token: testIdToken, options, initialLogin: true, onUpdate: onUpdateMock }) + const session: TokenSessionInstance = tokenSession({ + token: testIdToken, + options, + initialLogin: true, + onUpdate: onUpdateMock, + }) expect(onUpdateMock).toHaveBeenCalledWith( expect.objectContaining({ auth: expect.objectContaining({ JWT: testIdToken, - raw: expect.objectContaining({ anySpecialAttribute: "miau" }), + raw: expect.objectContaining({ anySpecialAttribute: "miau" }) as unknown, refreshToken: "TOKEN", - }), + }) as unknown, error: null, loggedIn: true, isProcessing: false, @@ -100,7 +106,7 @@ describe("tokenSession", () => { }) it("should log in and set correctly the state", () => { - const { login } = tokenSession({ + const { login }: TokenSessionInstance = tokenSession({ token: testIdToken, onUpdate: onUpdateMock, }) @@ -110,7 +116,7 @@ describe("tokenSession", () => { auth: expect.objectContaining({ JWT: testIdToken, refreshToken: "TOKEN", - }), + }) as unknown, error: null, loggedIn: true, isProcessing: false, @@ -119,7 +125,7 @@ describe("tokenSession", () => { }) it("should log out and reset the state", () => { - const { logout } = tokenSession({ + const { logout }: TokenSessionInstance = tokenSession({ token: testIdToken, onUpdate: onUpdateMock, }) @@ -137,7 +143,7 @@ describe("tokenSession", () => { throw new Error("This is not a valid token error message") }) - const { currentState } = tokenSession({ + const { currentState }: TokenSessionInstance = tokenSession({ token: "invalidToken", onUpdate: onUpdateMock, initialLogin: true, diff --git a/packages/oauth/eslint.config.mjs b/packages/oauth/eslint.config.mjs index 1901ef9a9f..6fdf437395 100644 --- a/packages/oauth/eslint.config.mjs +++ b/packages/oauth/eslint.config.mjs @@ -1,26 +1,13 @@ /* - * SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and Juno contributors + * SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and Juno contributors * SPDX-License-Identifier: Apache-2.0 */ -import junoConfigs from "@cloudoperators/juno-config/eslint/juno-typescript.mjs" +import junoConfigs from "@cloudoperators/juno-config/eslint/vite-react-ts.mjs" export default [ ...junoConfigs, { - files: ["**/*.ts", "**/*.tsx"], - languageOptions: { - parserOptions: { - project: ["./tsconfig.json"], // Ensure this points to your tsconfig.json - }, - }, - // TODO: We need to make all of this checks on again, step by step - rules: { - "@typescript-eslint/no-unsafe-assignment": "off", - "@typescript-eslint/no-unsafe-call": "off", - "@typescript-eslint/no-unsafe-argument": "off", - "@typescript-eslint/no-unsafe-member-access": "off", - }, ignores: ["vitest.config.ts", "vite.config.ts"], }, ] diff --git a/packages/oauth/src/codeFlow.ts b/packages/oauth/src/codeFlow.ts index 3cf986216a..05f7f0022d 100644 --- a/packages/oauth/src/codeFlow.ts +++ b/packages/oauth/src/codeFlow.ts @@ -51,7 +51,7 @@ export const exchangeCode = async ({ throw new Error(`Token exchange failed: ${response.statusText}`) } - const data: TokenResponse = await response.json() + const data = (await response.json()) as TokenResponse return data } @@ -155,13 +155,13 @@ const refreshToken = async ({ issuerURL, clientID, refreshToken }: RefreshTokenP .map((k) => `${encodeURIComponent(k)}=${encodeURIComponent(body[k])}`) .join("&") - const data: TokenResponse = await fetch(config.token_endpoint, { + const data = (await fetch(config.token_endpoint, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", }, body: formBody, - }).then((r) => r.json()) + }).then((r) => r.json())) as TokenResponse if ("error" in data && data.error) throw new Error(typeof data.error === "string" ? data.error : "Token refresh failed") diff --git a/packages/oauth/src/oidcConfig.ts b/packages/oauth/src/oidcConfig.ts index c7f34cb0c2..10b65a9c02 100644 --- a/packages/oauth/src/oidcConfig.ts +++ b/packages/oauth/src/oidcConfig.ts @@ -26,8 +26,9 @@ export async function getOidcConfig(issuerURL: string | URL): Promise { + const config = (await r.json()) as OidcConfig oidcConfig[issuerKey] = { - config: await r.json(), + config, time: Date.now(), } return oidcConfig[issuerKey].config diff --git a/packages/oauth/src/oidcSession.ts b/packages/oauth/src/oidcSession.ts index 275b564f36..f709ac8bd0 100644 --- a/packages/oauth/src/oidcSession.ts +++ b/packages/oauth/src/oidcSession.ts @@ -87,7 +87,8 @@ const createOidcRequest = async ({ // add additional search params if (requestParams) { - const params = typeof requestParams === "string" ? JSON.parse(requestParams) : requestParams + const params = + typeof requestParams === "string" ? (JSON.parse(requestParams) as Record) : requestParams const newUrl = new URL(url) Object.keys(params).forEach((k) => newUrl.searchParams.append(k, String(params[k]))) url = newUrl.href diff --git a/packages/oauth/src/oidcState.ts b/packages/oauth/src/oidcState.ts index d727320708..a59c94c55a 100644 --- a/packages/oauth/src/oidcState.ts +++ b/packages/oauth/src/oidcState.ts @@ -5,11 +5,13 @@ import type { OidcStateData } from "./types" import { encodeBase64Json, decodeBase64Json, randomString } from "./utils" -// @ts-ignore - oauth-pkce is a CommonJS module 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 +const getPkce = + typeof getPkceImport === "function" + ? getPkceImport + : (getPkceImport as { default?: typeof getPkceImport })?.default || getPkceImport // PKCE callback type from oauth-pkce library type PkceCallback = (_error: Error | null, _result: { verifier: string; challenge: string }) => void From d0c6b26f08bba374c46b84685968fde9fd71e63e Mon Sep 17 00:00:00 2001 From: Arturo Reuschenbach Puncernau Date: Mon, 5 Oct 2026 13:27:10 +0200 Subject: [PATCH 2/7] feat(oauth,config): introduce vite-ts ESLint config for TypeScript-only packages Signed-off-by: Arturo Reuschenbach Puncernau --- packages/config/eslint/vite-ts.mjs | 44 ++++++++++++++++++++++++++++++ packages/oauth/eslint.config.mjs | 2 +- 2 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 packages/config/eslint/vite-ts.mjs diff --git a/packages/config/eslint/vite-ts.mjs b/packages/config/eslint/vite-ts.mjs new file mode 100644 index 0000000000..3e0316ab4b --- /dev/null +++ b/packages/config/eslint/vite-ts.mjs @@ -0,0 +1,44 @@ +/* + * SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and Juno contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import js from "@eslint/js" +import globals from "globals" +import tseslint from "typescript-eslint" + +export default tseslint.config( + // Global ignores + { + ignores: ["**/build/*", "**/dist/*", "**/vite.config.ts.timestamp-*"], + }, + + // TypeScript-only configuration (no React) + // Uses tseslint.config() to resolve 'extends' at config-creation time so ESLint + // never sees the key — required for compatibility with ESLint flat config. + // Scopes all rules to TypeScript files only, preventing JS/TS rule conflicts. + { + files: ["**/*.ts"], + extends: [ + js.configs.recommended, + ...tseslint.configs.recommendedTypeChecked, + ], + languageOptions: { + globals: globals.node, + parserOptions: { + project: true, // Use nearest tsconfig.json + }, + }, + rules: { + // Allow unused vars starting with underscore + "@typescript-eslint/no-unused-vars": [ + "error", + { + argsIgnorePattern: "^_", + varsIgnorePattern: "^_", + caughtErrorsIgnorePattern: "^_", + }, + ], + }, + } +) diff --git a/packages/oauth/eslint.config.mjs b/packages/oauth/eslint.config.mjs index 6fdf437395..f6e49fb722 100644 --- a/packages/oauth/eslint.config.mjs +++ b/packages/oauth/eslint.config.mjs @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import junoConfigs from "@cloudoperators/juno-config/eslint/vite-react-ts.mjs" +import junoConfigs from "@cloudoperators/juno-config/eslint/vite-ts.mjs" export default [ ...junoConfigs, From 01c9e05526d6e06089ea74a9908ff72a90c14740 Mon Sep 17 00:00:00 2001 From: Arturo Reuschenbach Puncernau Date: Mon, 5 Oct 2026 13:28:59 +0200 Subject: [PATCH 3/7] feat(oauth): changeset added and adapted Signed-off-by: Arturo Reuschenbach Puncernau --- .changeset/oauth-eslint-strict-types.md | 2 +- .changeset/vite-ts-config.md | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) create mode 100644 .changeset/vite-ts-config.md diff --git a/.changeset/oauth-eslint-strict-types.md b/.changeset/oauth-eslint-strict-types.md index d098a951f5..910a523458 100644 --- a/.changeset/oauth-eslint-strict-types.md +++ b/.changeset/oauth-eslint-strict-types.md @@ -2,4 +2,4 @@ "@cloudoperators/juno-oauth": patch --- -chore(oauth): enable strict TypeScript ESLint rules +feat(oauth): migrate to vite-ts ESLint config for TypeScript-only packages diff --git a/.changeset/vite-ts-config.md b/.changeset/vite-ts-config.md new file mode 100644 index 0000000000..d37ccdefee --- /dev/null +++ b/.changeset/vite-ts-config.md @@ -0,0 +1,7 @@ +--- +"@cloudoperators/juno-config": patch +--- + +feat(config): add vite-ts.mjs ESLint configuration for TypeScript-only packages + +Adds a new vite-ts.mjs ESLint configuration designed for pure TypeScript packages without React dependencies. This config provides strict type-aware linting using recommendedTypeChecked and completes the modern config family alongside vite-react-ts.mjs. From e5079df6cd27b2d3d84a9ae9d87fc9d2c6da99fc Mon Sep 17 00:00:00 2001 From: Arturo Reuschenbach Puncernau Date: Mon, 5 Oct 2026 14:01:52 +0200 Subject: [PATCH 4/7] chore(oauth): ai suggestions Signed-off-by: Arturo Reuschenbach Puncernau --- .changeset/oauth-eslint-strict-types.md | 17 ++++++- packages/oauth/__tests__/codeFlow.test.ts | 6 ++- packages/oauth/__tests__/oidcConfig.test.ts | 6 ++- packages/oauth/package.json | 3 +- packages/oauth/src/codeFlow.ts | 15 ++++-- packages/oauth/src/oidcConfig.ts | 7 ++- packages/oauth/src/oidcSession.ts | 6 ++- packages/oauth/src/schemas.ts | 54 +++++++++++++++++++++ pnpm-lock.yaml | 3 ++ 9 files changed, 106 insertions(+), 11 deletions(-) create mode 100644 packages/oauth/src/schemas.ts diff --git a/.changeset/oauth-eslint-strict-types.md b/.changeset/oauth-eslint-strict-types.md index 910a523458..4bb1129c7c 100644 --- a/.changeset/oauth-eslint-strict-types.md +++ b/.changeset/oauth-eslint-strict-types.md @@ -1,5 +1,18 @@ --- -"@cloudoperators/juno-oauth": patch +"@cloudoperators/juno-oauth": minor --- -feat(oauth): migrate to vite-ts ESLint config for TypeScript-only packages +feat(oauth): add runtime validation with Zod and migrate to vite-ts ESLint config + +Introduces runtime validation for OAuth/OIDC responses using Zod schemas, addressing type safety at external API boundaries. Also migrates the package to use the new vite-ts.mjs ESLint configuration designed for TypeScript-only packages. + +**Runtime Validation:** +- Added Zod schemas for TokenResponse and OidcConfig validation +- Validates token endpoint responses before type assertions +- Validates OIDC discovery configuration responses +- Provides clear error messages when API responses are malformed + +**ESLint Migration:** +- Introduced new vite-ts.mjs config for pure TypeScript packages +- Migrated OAuth from vite-react-ts.mjs to vite-ts.mjs +- Maintains strict type-aware linting without unnecessary React dependencies diff --git a/packages/oauth/__tests__/codeFlow.test.ts b/packages/oauth/__tests__/codeFlow.test.ts index 218783e0d4..a2d8bc6cc2 100644 --- a/packages/oauth/__tests__/codeFlow.test.ts +++ b/packages/oauth/__tests__/codeFlow.test.ts @@ -22,7 +22,11 @@ const mockIdTokenResponse = { ok: true, statusText: "OK", json: () => { - return { id_token: testIdToken } + return { + access_token: "mock_access_token", + token_type: "Bearer", + id_token: testIdToken, + } }, } vi.stubGlobal("fetch", vi.fn().mockResolvedValue(mockIdTokenResponse)) diff --git a/packages/oauth/__tests__/oidcConfig.test.ts b/packages/oauth/__tests__/oidcConfig.test.ts index 657286d3e5..a3ff20a121 100644 --- a/packages/oauth/__tests__/oidcConfig.test.ts +++ b/packages/oauth/__tests__/oidcConfig.test.ts @@ -9,7 +9,11 @@ import { getOidcConfig, resetCache } from "../src/oidcConfig" const mockResponse = { ok: true, statusText: "OK", - json: async () => {}, + json: () => + Promise.resolve({ + authorization_endpoint: "https://test.com/authorize", + token_endpoint: "https://test.com/token", + }), } as Response const mockFetch = vi.fn().mockResolvedValue(mockResponse) vi.stubGlobal("fetch", mockFetch) diff --git a/packages/oauth/package.json b/packages/oauth/package.json index 03a58ec4f6..4c3d1a6f79 100644 --- a/packages/oauth/package.json +++ b/packages/oauth/package.json @@ -26,7 +26,8 @@ "vitest": "4.1.11" }, "dependencies": { - "oauth-pkce": "0.0.7" + "oauth-pkce": "0.0.7", + "zod": "^4.6.5" }, "scripts": { "test": "vitest run", diff --git a/packages/oauth/src/codeFlow.ts b/packages/oauth/src/codeFlow.ts index 05f7f0022d..a38d821fa6 100644 --- a/packages/oauth/src/codeFlow.ts +++ b/packages/oauth/src/codeFlow.ts @@ -8,6 +8,7 @@ import { getOidcConfig } from "./oidcConfig" import { decodeIDToken } from "./tokenHelpers" import { searchParams } from "./oidcState" import { paramsToUrl } from "./utils" +import { TokenResponseSchema } from "./schemas" interface ExchangeCodeParams { tokenEndpoint: string @@ -51,7 +52,8 @@ export const exchangeCode = async ({ throw new Error(`Token exchange failed: ${response.statusText}`) } - const data = (await response.json()) as TokenResponse + const json: unknown = await response.json() + const data: TokenResponse = TokenResponseSchema.parse(json) return data } @@ -155,13 +157,20 @@ const refreshToken = async ({ issuerURL, clientID, refreshToken }: RefreshTokenP .map((k) => `${encodeURIComponent(k)}=${encodeURIComponent(body[k])}`) .join("&") - const data = (await fetch(config.token_endpoint, { + const response = await fetch(config.token_endpoint, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", }, body: formBody, - }).then((r) => r.json())) as TokenResponse + }) + + if (!response.ok) { + throw new Error(`Token refresh failed: ${response.statusText}`) + } + + const json: unknown = await response.json() + const data: TokenResponse = TokenResponseSchema.parse(json) if ("error" in data && data.error) throw new Error(typeof data.error === "string" ? data.error : "Token refresh failed") diff --git a/packages/oauth/src/oidcConfig.ts b/packages/oauth/src/oidcConfig.ts index 10b65a9c02..5353b2031a 100644 --- a/packages/oauth/src/oidcConfig.ts +++ b/packages/oauth/src/oidcConfig.ts @@ -4,6 +4,7 @@ */ import type { OidcConfig, CachedConfig } from "./types" +import { OidcConfigSchema } from "./schemas" let oidcConfig: Record = {} const cacheDuration = 5 * 60 * 60 * 1000 @@ -26,7 +27,11 @@ export async function getOidcConfig(issuerURL: string | URL): Promise { - const config = (await r.json()) as OidcConfig + if (!r.ok) { + throw new Error(`Failed to fetch OIDC config: ${r.statusText}`) + } + const json: unknown = await r.json() + const config: OidcConfig = OidcConfigSchema.parse(json) oidcConfig[issuerKey] = { config, time: Date.now(), diff --git a/packages/oauth/src/oidcSession.ts b/packages/oauth/src/oidcSession.ts index f709ac8bd0..94f0d894d2 100644 --- a/packages/oauth/src/oidcSession.ts +++ b/packages/oauth/src/oidcSession.ts @@ -20,6 +20,7 @@ import * as codeFlowHandler from "./codeFlow" import { hasValidState, createState as createRequestState, getState as getResponseState } from "./oidcState" import { getOidcConfig } from "./oidcConfig" import { OAuthError } from "./OAuthError" +import { RequestParamsSchema } from "./schemas" // define flow types export const FLOW_TYPE = { @@ -87,8 +88,9 @@ const createOidcRequest = async ({ // add additional search params if (requestParams) { - const params = - typeof requestParams === "string" ? (JSON.parse(requestParams) as Record) : requestParams + const parsed: unknown = + typeof requestParams === "string" ? JSON.parse(requestParams) : requestParams + const params = RequestParamsSchema.parse(parsed) const newUrl = new URL(url) Object.keys(params).forEach((k) => newUrl.searchParams.append(k, String(params[k]))) url = newUrl.href diff --git a/packages/oauth/src/schemas.ts b/packages/oauth/src/schemas.ts new file mode 100644 index 0000000000..2b2da0a3ac --- /dev/null +++ b/packages/oauth/src/schemas.ts @@ -0,0 +1,54 @@ +/* + * SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and Juno contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { z } from "zod" + +/** + * Zod schema for validating OAuth2/OIDC token responses + * Based on RFC 6749 and OpenID Connect Core 1.0 + */ +export const TokenResponseSchema = z + .object({ + /** REQUIRED. The access token issued by the authorization server */ + access_token: z.string(), + /** REQUIRED. The type of the token (typically "Bearer") */ + token_type: z.string(), + /** RECOMMENDED. The lifetime in seconds of the access token */ + expires_in: z.number().optional(), + /** OPTIONAL. The refresh token for obtaining new access tokens */ + refresh_token: z.string().optional(), + /** OPTIONAL. The scope of the access token */ + scope: z.string().optional(), + /** OPTIONAL. The ID token (OpenID Connect) */ + id_token: z.string().optional(), + }) + .catchall(z.unknown()) // Allow additional fields from token endpoint + +/** + * Zod schema for OIDC Discovery configuration + * Based on OpenID Connect Discovery 1.0 specification + */ +export const OidcConfigSchema = z + .object({ + /** REQUIRED. URL of the OP's OAuth 2.0 Authorization Endpoint */ + authorization_endpoint: z.string().url(), + /** REQUIRED. URL of the OP's OAuth 2.0 Token Endpoint */ + token_endpoint: z.string().url(), + /** OPTIONAL. URL of the OP's OAuth 2.0 Revocation Endpoint */ + revocation_endpoint: z.string().url().optional(), + /** OPTIONAL. URL of the OP's OAuth 2.0 Introspection Endpoint */ + introspection_endpoint: z.string().url().optional(), + /** RECOMMENDED. URL of the OP's UserInfo Endpoint */ + userinfo_endpoint: z.string().url().optional(), + /** OPTIONAL. URL of the OP's logout endpoint */ + end_session_endpoint: z.string().url().optional(), + }) + .catchall(z.unknown()) // Allow additional fields from discovery document + +/** + * Zod schema for validating OAuth request parameters + * Used for additional parameters passed to authorization endpoint + */ +export const RequestParamsSchema = z.record(z.string(), z.unknown()) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c08621ed16..d561107d79 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -794,6 +794,9 @@ importers: oauth-pkce: specifier: 0.0.7 version: 0.0.7 + zod: + specifier: ^4.6.5 + version: 4.6.5 devDependencies: '@cloudoperators/juno-config': specifier: workspace:* From b80bc142eb34a89254b64a250aa099903a9da978 Mon Sep 17 00:00:00 2001 From: Arturo Reuschenbach Puncernau Date: Mon, 5 Oct 2026 14:05:11 +0200 Subject: [PATCH 5/7] chore(oauth): ai suggestions Signed-off-by: Arturo Reuschenbach Puncernau --- packages/oauth/src/oidcSession.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/oauth/src/oidcSession.ts b/packages/oauth/src/oidcSession.ts index 94f0d894d2..6d14699c1c 100644 --- a/packages/oauth/src/oidcSession.ts +++ b/packages/oauth/src/oidcSession.ts @@ -88,8 +88,7 @@ const createOidcRequest = async ({ // add additional search params if (requestParams) { - const parsed: unknown = - typeof requestParams === "string" ? JSON.parse(requestParams) : requestParams + const parsed: unknown = typeof requestParams === "string" ? JSON.parse(requestParams) : requestParams const params = RequestParamsSchema.parse(parsed) const newUrl = new URL(url) Object.keys(params).forEach((k) => newUrl.searchParams.append(k, String(params[k]))) From 16f5db11cde1e3d84bd07a6f9852a3fb0997b145 Mon Sep 17 00:00:00 2001 From: Arturo Reuschenbach Puncernau Date: Mon, 5 Oct 2026 14:09:40 +0200 Subject: [PATCH 6/7] chore(oauth): ai suggestions Signed-off-by: Arturo Reuschenbach Puncernau --- packages/oauth/package.json | 2 +- pnpm-lock.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/oauth/package.json b/packages/oauth/package.json index 4c3d1a6f79..4482d21b48 100644 --- a/packages/oauth/package.json +++ b/packages/oauth/package.json @@ -27,7 +27,7 @@ }, "dependencies": { "oauth-pkce": "0.0.7", - "zod": "^4.6.5" + "zod": "4.6.5" }, "scripts": { "test": "vitest run", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d561107d79..79f3b7fc36 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -795,7 +795,7 @@ importers: specifier: 0.0.7 version: 0.0.7 zod: - specifier: ^4.6.5 + specifier: 4.6.5 version: 4.6.5 devDependencies: '@cloudoperators/juno-config': From c5168a1c40173b2c7ebe2916ab4f10b1c31bb38e Mon Sep 17 00:00:00 2001 From: Arturo Reuschenbach Puncernau Date: Mon, 5 Oct 2026 14:13:39 +0200 Subject: [PATCH 7/7] chore(oauth): ai suggestions Signed-off-by: Arturo Reuschenbach Puncernau --- packages/oauth/__tests__/schemas.test.ts | 196 +++++++++++++++++++++++ 1 file changed, 196 insertions(+) create mode 100644 packages/oauth/__tests__/schemas.test.ts diff --git a/packages/oauth/__tests__/schemas.test.ts b/packages/oauth/__tests__/schemas.test.ts new file mode 100644 index 0000000000..de6c36b293 --- /dev/null +++ b/packages/oauth/__tests__/schemas.test.ts @@ -0,0 +1,196 @@ +/* + * SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and Juno contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, test } from "vitest" +import { TokenResponseSchema, OidcConfigSchema, RequestParamsSchema } from "../src/schemas" + +describe("TokenResponseSchema", () => { + test("should validate a valid token response", () => { + const validResponse = { + access_token: "abc123", + token_type: "Bearer", + expires_in: 3600, + refresh_token: "refresh123", + scope: "openid profile email", + id_token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + } + + expect(() => TokenResponseSchema.parse(validResponse)).not.toThrow() + const result = TokenResponseSchema.parse(validResponse) + expect(result.access_token).toBe("abc123") + expect(result.token_type).toBe("Bearer") + }) + + test("should reject response missing access_token", () => { + const invalidResponse = { + token_type: "Bearer", + } + + expect(() => TokenResponseSchema.parse(invalidResponse)).toThrow() + }) + + test("should reject response missing token_type", () => { + const invalidResponse = { + access_token: "abc123", + } + + expect(() => TokenResponseSchema.parse(invalidResponse)).toThrow() + }) + + test("should reject response with wrong type for access_token", () => { + const invalidResponse = { + access_token: 12345, // should be string + token_type: "Bearer", + } + + expect(() => TokenResponseSchema.parse(invalidResponse)).toThrow() + }) + + test("should reject null response", () => { + expect(() => TokenResponseSchema.parse(null)).toThrow() + }) + + test("should reject array response", () => { + expect(() => TokenResponseSchema.parse([])).toThrow() + }) + + test("should reject primitive response", () => { + expect(() => TokenResponseSchema.parse("invalid")).toThrow() + }) + + test("should accept response with additional fields", () => { + const responseWithExtras = { + access_token: "abc123", + token_type: "Bearer", + session_state: "xyz789", // Keycloak-specific + not_before_policy: 0, // Keycloak-specific + custom_claim: "value", + } + + expect(() => TokenResponseSchema.parse(responseWithExtras)).not.toThrow() + const result = TokenResponseSchema.parse(responseWithExtras) + expect(result.session_state).toBe("xyz789") + }) + + test("should accept minimal valid response", () => { + const minimalResponse = { + access_token: "abc123", + token_type: "Bearer", + } + + expect(() => TokenResponseSchema.parse(minimalResponse)).not.toThrow() + }) +}) + +describe("OidcConfigSchema", () => { + test("should validate a valid OIDC config", () => { + const validConfig = { + authorization_endpoint: "https://issuer.com/authorize", + token_endpoint: "https://issuer.com/token", + userinfo_endpoint: "https://issuer.com/userinfo", + end_session_endpoint: "https://issuer.com/logout", + } + + expect(() => OidcConfigSchema.parse(validConfig)).not.toThrow() + const result = OidcConfigSchema.parse(validConfig) + expect(result.authorization_endpoint).toBe("https://issuer.com/authorize") + }) + + test("should reject config missing authorization_endpoint", () => { + const invalidConfig = { + token_endpoint: "https://issuer.com/token", + } + + expect(() => OidcConfigSchema.parse(invalidConfig)).toThrow() + }) + + test("should reject config missing token_endpoint", () => { + const invalidConfig = { + authorization_endpoint: "https://issuer.com/authorize", + } + + expect(() => OidcConfigSchema.parse(invalidConfig)).toThrow() + }) + + test("should reject config with invalid URL format", () => { + const invalidConfig = { + authorization_endpoint: "not-a-url", + token_endpoint: "https://issuer.com/token", + } + + expect(() => OidcConfigSchema.parse(invalidConfig)).toThrow() + }) + + test("should reject null config", () => { + expect(() => OidcConfigSchema.parse(null)).toThrow() + }) + + test("should reject array config", () => { + expect(() => OidcConfigSchema.parse([])).toThrow() + }) + + test("should accept config with additional discovery fields", () => { + const configWithExtras = { + authorization_endpoint: "https://issuer.com/authorize", + token_endpoint: "https://issuer.com/token", + scopes_supported: ["openid", "profile", "email"], + response_types_supported: ["code", "token"], + } + + expect(() => OidcConfigSchema.parse(configWithExtras)).not.toThrow() + const result = OidcConfigSchema.parse(configWithExtras) + expect(result.scopes_supported).toEqual(["openid", "profile", "email"]) + }) + + test("should accept minimal valid config", () => { + const minimalConfig = { + authorization_endpoint: "https://issuer.com/authorize", + token_endpoint: "https://issuer.com/token", + } + + expect(() => OidcConfigSchema.parse(minimalConfig)).not.toThrow() + }) +}) + +describe("RequestParamsSchema", () => { + test("should validate a valid request params object", () => { + const validParams = { + prompt: "consent", + max_age: "3600", + display: "popup", + } + + expect(() => RequestParamsSchema.parse(validParams)).not.toThrow() + const result = RequestParamsSchema.parse(validParams) + expect(result.prompt).toBe("consent") + }) + + test("should accept empty object", () => { + expect(() => RequestParamsSchema.parse({})).not.toThrow() + }) + + test("should reject null", () => { + expect(() => RequestParamsSchema.parse(null)).toThrow() + }) + + test("should reject array", () => { + expect(() => RequestParamsSchema.parse([])).toThrow() + }) + + test("should reject primitive", () => { + expect(() => RequestParamsSchema.parse("invalid")).toThrow() + }) + + test("should accept params with various value types", () => { + const params = { + string_param: "value", + number_param: 123, + boolean_param: true, + null_param: null, + } + + expect(() => RequestParamsSchema.parse(params)).not.toThrow() + }) +})