diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index dd65f242..bacee319 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -10,6 +10,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Added +- Added inference of dynamic parameters in OpenID Connect (OIDC) provider issuers. Issuers containing dynamic segments (prefixed with `:`) are now automatically detected, and users are required to provide values for those parameters when configuring the provider. [#200](https://github.com/aura-stack-ts/auth/pull/200) + - Integrated `@aura-stack/rate-limiter` into authentication flows. Rate limiting is now enforced for `signIn`, `signInCredentials`, `signUp`, and `updateSession` actions, providing built-in protection against abuse and excessive requests. [#194](https://github.com/aura-stack-ts/auth/pull/194) - Extended `UserFrom` to support type inference from `ArkType` and `Valibot` schemas. User types are now inferred correctly for `Zod`, `ArkType`, and `Valibot` schema definitions. [#191](https://github.com/aura-stack-ts/auth/pull/191) diff --git a/packages/core/src/@types/oidc.ts b/packages/core/src/@types/oidc.ts index 74331a92..0ccf1614 100644 --- a/packages/core/src/@types/oidc.ts +++ b/packages/core/src/@types/oidc.ts @@ -1,4 +1,5 @@ import type { User } from "@/@types/session.ts" +import type { GetRouteParams } from "@aura-stack/router/types" /** * @link https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata @@ -206,13 +207,13 @@ export interface OpenIDMetadata { op_tos_uri?: string } -export interface OpenIDProvider, DefaultUser = User> { +export type OpenIDProvider, DefaultUser = User, Issuer extends string = string> = { id: string name: string /** * URL to concatenating the string /.well-known/openid-configuration to the Issuer. */ - issuer: string + issuer: Issuer clientId?: string clientSecret?: string /** @@ -220,4 +221,4 @@ export interface OpenIDProvider, De */ scope?: string profile?: (profile: Profile) => DefaultUser | Promise -} +} & GetRouteParams<`/${Issuer}`> diff --git a/packages/core/src/actions/oidc/resolve-provider.ts b/packages/core/src/actions/oidc/resolve-provider.ts index a9624e99..de42cb1c 100644 --- a/packages/core/src/actions/oidc/resolve-provider.ts +++ b/packages/core/src/actions/oidc/resolve-provider.ts @@ -1,6 +1,7 @@ import { discoveryMetadata } from "@/actions/oidc/discovery.ts" import type { OpenIDProvider } from "@/@types/oidc.ts" import type { RuntimeOAuthProvider } from "@/@types/oauth.ts" +import { setDynamicParams } from "@/oauth/index.ts" const DEFAULT_OIDC_SCOPE = "openid profile email" @@ -16,10 +17,11 @@ export const resolveOpenIDProvider = async (provider: RuntimeOAuthProvider): Pro return cached } - const issuer = provider.oidc?.issuer + let issuer = provider.oidc?.issuer if (!issuer) { throw new Error("OIDC provider is missing issuer configuration: " + provider.id) } + issuer = setDynamicParams(issuer, provider as unknown as Record) const metadata = await discoveryMetadata(issuer) const scope = @@ -68,7 +70,7 @@ export const createOpenIDPlaceholder = ( accessToken: "", userInfo: "", oidc: { - issuer: config.issuer, + issuer: setDynamicParams(config.issuer, config), }, } } diff --git a/packages/core/src/oauth/index.ts b/packages/core/src/oauth/index.ts index cb0d7a3b..ef257415 100644 --- a/packages/core/src/oauth/index.ts +++ b/packages/core/src/oauth/index.ts @@ -91,12 +91,23 @@ const isOpenIDProvider = (config: BuiltInOAuthProvider | RuntimeOAuthProvider | return typeof config === "object" && "issuer" in config && !("accessToken" in config) } -const defineOpenIDProviderConfig = (config: OpenIDProvider): RuntimeOAuthProvider => { +export const setDynamicParams = >(template: T, params: P): string => { + return template.replace(/(^|\/):([A-Za-z_][A-Za-z0-9_]*)/g, (_, prefix, key) => { + const value = params[key] + if (value == null) { + throw new AuraAuthError({ code: "OIDC_INVALID_ISSUER_PARAMS" }) + } + return `${prefix}${encodeURIComponent(String(value))}` + }) +} + +export const defineOpenIDProviderConfig = (config: OpenIDProvider): RuntimeOAuthProvider => { const parsed = OpenIDProviderSchema.safeParse(config) if (!parsed.success) { throw new AuraAuthError({ code: "INVALID_OAUTH_PROVIDER_SCHEMA_CONFIG", cause: parsed.error }) } const envConfig = !config.clientId || !config.clientSecret ? defineOAuthEnvironment(config.id) : undefined + config.issuer = setDynamicParams(config.issuer, config) return createOpenIDPlaceholder(config, { clientId: config.clientId || envConfig!.clientId, clientSecret: config.clientSecret || envConfig!.clientSecret, diff --git a/packages/core/src/shared/errors.ts b/packages/core/src/shared/errors.ts index 9311a432..98e0b0e8 100644 --- a/packages/core/src/shared/errors.ts +++ b/packages/core/src/shared/errors.ts @@ -109,6 +109,7 @@ export const AuraErrorCode = { OIDC_USERINFO_INVALID_SCHEMA: "OIDC_USERINFO_INVALID_SCHEMA", OIDC_JWKS_INVALID_RESPONSE: "OIDC_JWKS_INVALID_RESPONSE", OIDC_JWKS_INVALID_SCHEMA: "OIDC_JWKS_INVALID_SCHEMA", + OIDC_INVALID_ISSUER_PARAMS: "OIDC_INVALID_ISSUER_PARAMS", } as const export type AuraErrorCode = (typeof AuraErrorCode)[keyof typeof AuraErrorCode] @@ -798,6 +799,13 @@ export const ERROR_CATALOG: Record = { "The JWKS document failed structural validation against the JSON Web Key Set schema. Required keys array may be missing or malformed.", userMessage: "The identity provider key set format is invalid.", }, + OIDC_INVALID_ISSUER_PARAMS: { + type: "VALIDATION", + statusCode: 502, + name: "OIDCInvalidIssuerError", + message: "The configured OpenID Connect issuer parameters are missing or invalid, preventing issuer validation.", + userMessage: "The identity provider configuration is invalid. Please check issuer settings and try again.", + }, } export interface AuraErrorOptions extends ErrorOptions { diff --git a/packages/core/test/actions/oidc/discovery.test.ts b/packages/core/test/actions/oidc/discovery.test.ts index cf55a0cb..2be3818b 100644 --- a/packages/core/test/actions/oidc/discovery.test.ts +++ b/packages/core/test/actions/oidc/discovery.test.ts @@ -31,6 +31,26 @@ describe("discoveryMetadata", () => { ) }) + test("fetches and validates discovery metadata - issuer with slig", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: true, + headers: new Headers({ "Content-Type": "application/json" }), + json: async () => ({ ...openIDMetadata, issuer: "https://app.com/issuer/1/apps/2" }), + })) + ) + + const metadata = await discoveryMetadata("https://app.com/issuer/1/apps/2") + expect(metadata).toEqual({ ...openIDMetadata, issuer: "https://app.com/issuer/1/apps/2" }) + expect(fetch).toHaveBeenCalledWith( + "https://app.com/issuer/1/apps/2/.well-known/openid-configuration", + expect.objectContaining({ + headers: { Accept: "application/json" }, + }) + ) + }) + test("normalizes trailing slash on issuer comparison", async () => { vi.stubGlobal( "fetch", diff --git a/packages/core/test/actions/oidc/resolve-provider.test.ts b/packages/core/test/actions/oidc/resolve-provider.test.ts index e2ef2413..ac05faac 100644 --- a/packages/core/test/actions/oidc/resolve-provider.test.ts +++ b/packages/core/test/actions/oidc/resolve-provider.test.ts @@ -69,4 +69,26 @@ describe("resolveOpenIDProvider", () => { expect(isOIDCProvider({ oidc: { issuer: "https://id.example.com" } })).toBe(true) expect(isOIDCProvider({})).toBe(false) }) + + test("resolve OIDC provider with dynamic params", async () => { + const fetchMock = vi.fn(async () => ({ + ok: true, + headers: new Headers({ "Content-Type": "application/json" }), + json: async () => ({ ...openIDMetadata, issuer: "https://app.com/issuer/1/apps/2" }), + })) + vi.stubGlobal("fetch", fetchMock) + + const oidcProvider = { + ...openIDCustomProvider, + issuer: "https://app.com/issuer/:teamId/apps/:appId", + teamId: 1, + appId: 2, + } + const placeholder = createOpenIDPlaceholder(oidcProvider, { + clientId: oidcProvider.clientId!, + clientSecret: oidcProvider.clientSecret!, + }) + + await resolveOpenIDProvider(placeholder) + }) }) diff --git a/packages/core/test/oauth.test.ts b/packages/core/test/oauth.test.ts index 12092340..88cff8cc 100644 --- a/packages/core/test/oauth.test.ts +++ b/packages/core/test/oauth.test.ts @@ -1,6 +1,14 @@ import { describe, test, expect } from "vitest" -import { createBuiltInOAuthProviders, builtInOAuthProviders, type GitHubProfile } from "@/oauth/index.ts" +import { + createBuiltInOAuthProviders, + builtInOAuthProviders, + type GitHubProfile, + setDynamicParams, + defineOpenIDProviderConfig, +} from "@/oauth/index.ts" import type { OAuthProviderCredentials, User } from "@/@types/index.ts" +import { AuraAuthError } from "@/shared/errors.ts" +import { openIDCustomProvider } from "./presets.ts" describe("createBuiltInOAuthProviders", () => { test("create oauth config for github", () => { @@ -55,4 +63,139 @@ describe("createBuiltInOAuthProviders", () => { "The registration collection contains duplicate identifier keys. Unique registration indices are mandatory across tracking providers" ) }) + + test("create oidc config", () => { + const oidc = createBuiltInOAuthProviders([openIDCustomProvider]) + expect(oidc["oidc-provider"]).toMatchObject({ + id: "oidc-provider", + name: "OIDC", + clientId: "oidc_client_id", + clientSecret: "oidc_client_secret", + oidc: { + issuer: "https://id.example.com", + }, + }) + }) + + test("create oidc config with slugs in issuer", () => { + const oidc = createBuiltInOAuthProviders([ + { ...openIDCustomProvider, issuer: "https://app.com/issuer/:teamId/apps/:appId", teamId: 1, appId: 2 } as any, + ]) + expect(oidc["oidc-provider"]).toMatchObject({ + id: "oidc-provider", + name: "OIDC", + clientId: "oidc_client_id", + clientSecret: "oidc_client_secret", + oidc: { + issuer: "https://app.com/issuer/1/apps/2", + }, + }) + }) +}) + +describe("setDynamicParams", () => { + describe("valid cases", () => { + const testCases = [ + { + description: "set without dynamic param", + input: "https://app.com/issuer", + values: {}, + expected: "https://app.com/issuer", + }, + { + description: "set one dynamic param", + input: "https://app.com/issuer/:slug", + values: { slug: 1 }, + expected: "https://app.com/issuer/1", + }, + { + description: "set two dynamic params", + input: "https://app.com/issuer/:slug/apps/:appId", + values: { slug: 1, appId: 2 }, + expected: "https://app.com/issuer/1/apps/2", + }, + { + description: "set two continue params", + input: "https://app.com/issuer/:slug/:id", + values: { slug: 1, id: 2 }, + expected: "https://app.com/issuer/1/2", + }, + { + description: "set dynamic param with host", + input: "https://host:8443/realms/acme", + values: {}, + expected: "https://host:8443/realms/acme", + }, + { + description: "set dynamic param with host and path", + input: "https://host:8443/realms/:realm", + values: { realm: "acme" }, + expected: "https://host:8443/realms/acme", + }, + ] + + for (const { description, input, values, expected } of testCases) { + test(description, () => { + expect(setDynamicParams(input, values)).toBe(expected) + }) + } + }) + + describe("invalid cases", () => { + const testCases = [ + { + description: "missing dynamic values", + input: "https://app.com/issuer/:slug", + values: {}, + }, + ] + + for (const { description, input, values } of testCases) { + test(description, () => { + expect(() => setDynamicParams(input, values)).toThrow(AuraAuthError) + }) + } + }) +}) + +describe("defineOpenIDProviderConfig", () => { + test("default oidc provider", () => { + const oidc = defineOpenIDProviderConfig({ + id: "oidc", + name: "OIDC", + issuer: "https://app.com/issuer", + clientId: "oidc_id", + clientSecret: "oidc_secret", + }) + expect(oidc).toMatchObject({ + id: "oidc", + name: "OIDC", + clientId: "oidc_id", + clientSecret: "oidc_secret", + oidc: { + issuer: "https://app.com/issuer", + }, + }) + }) + + test("oidc provider with dynamic params", () => { + const oidc = defineOpenIDProviderConfig({ + id: "oidc", + name: "OIDC", + issuer: "https://app.com/issuer/:teamId/apps/:appId", + clientId: "oidc_id", + clientSecret: "oidc_secret", + teamId: 1, + appId: 2, + } as any) + expect(oidc).toMatchObject({ + id: "oidc", + name: "OIDC", + clientId: "oidc_id", + clientSecret: "oidc_secret", + oidc: { + issuer: "https://app.com/issuer/1/apps/2", + }, + }) + }) }) diff --git a/packages/elysia/package.json b/packages/elysia/package.json index 2a8be41e..5389ffe8 100644 --- a/packages/elysia/package.json +++ b/packages/elysia/package.json @@ -104,4 +104,4 @@ "elysia": ">=1.0.0" }, "packageManager": "pnpm@10.15.0" -} +} \ No newline at end of file diff --git a/packages/express/package.json b/packages/express/package.json index 732d61c0..d2bcfdfe 100644 --- a/packages/express/package.json +++ b/packages/express/package.json @@ -109,4 +109,4 @@ "express": ">=4.0.0" }, "packageManager": "pnpm@10.15.0" -} +} \ No newline at end of file diff --git a/packages/hono/package.json b/packages/hono/package.json index 40b75c6e..32887f97 100644 --- a/packages/hono/package.json +++ b/packages/hono/package.json @@ -107,4 +107,4 @@ "hono": ">=4.0.0" }, "packageManager": "pnpm@10.15.0" -} +} \ No newline at end of file diff --git a/packages/next/package.json b/packages/next/package.json index 8fe8e796..59135c2f 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -142,4 +142,4 @@ "react-dom": ">=19.0.0" }, "packageManager": "pnpm@10.15.0" -} +} \ No newline at end of file diff --git a/packages/react-router/package.json b/packages/react-router/package.json index 4b8f0f63..1f7ea1d5 100644 --- a/packages/react-router/package.json +++ b/packages/react-router/package.json @@ -114,4 +114,4 @@ "react-router": ">=7.0.0" }, "packageManager": "pnpm@10.15.0" -} +} \ No newline at end of file diff --git a/packages/react/package.json b/packages/react/package.json index ae6d5244..dad8503f 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -126,4 +126,4 @@ "@types/react": ">=19.0.0" }, "packageManager": "pnpm@10.15.0" -} +} \ No newline at end of file