Skip to content
Merged
18 changes: 18 additions & 0 deletions .changeset/oauth-eslint-strict-types.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
"@cloudoperators/juno-oauth": minor
---

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
7 changes: 7 additions & 0 deletions .changeset/vite-ts-config.md
Original file line number Diff line number Diff line change
@@ -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.
44 changes: 44 additions & 0 deletions packages/config/eslint/vite-ts.mjs
Original file line number Diff line number Diff line change
@@ -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: "^_",
},
],
},
}
)
6 changes: 5 additions & 1 deletion packages/oauth/__tests__/codeFlow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
7 changes: 4 additions & 3 deletions packages/oauth/__tests__/implicitFlow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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)
})
})
})
10 changes: 5 additions & 5 deletions packages/oauth/__tests__/mockedSession.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ describe("mockedSession", () => {
session.logout()
session.login()
expect(onUpdate).toHaveBeenLastCalledWith({
auth: expect.anything(),
auth: expect.anything() as unknown,
error: null,
loggedIn: true,
isProcessing: false,
Expand Down Expand Up @@ -132,10 +132,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,
Expand Down
6 changes: 5 additions & 1 deletion packages/oauth/__tests__/oidcConfig.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion packages/oauth/__tests__/oidcSession.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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" })
})
Expand Down
196 changes: 196 additions & 0 deletions packages/oauth/__tests__/schemas.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
Loading
Loading