Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/core/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
7 changes: 4 additions & 3 deletions packages/core/src/@types/oidc.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -206,18 +207,18 @@ export interface OpenIDMetadata {
op_tos_uri?: string
}

export interface OpenIDProvider<Profile extends object = Record<string, any>, DefaultUser = User> {
export type OpenIDProvider<Profile extends object = Record<string, any>, 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
/**
* Override the default OIDC scope (`openid profile email`).
*/
scope?: string
profile?: (profile: Profile) => DefaultUser | Promise<DefaultUser>
}
} & GetRouteParams<`/${Issuer}`>
6 changes: 4 additions & 2 deletions packages/core/src/actions/oidc/resolve-provider.ts
Original file line number Diff line number Diff line change
@@ -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"

Expand All @@ -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<string, unknown>)

const metadata = await discoveryMetadata(issuer)
const scope =
Expand Down Expand Up @@ -68,7 +70,7 @@ export const createOpenIDPlaceholder = (
accessToken: "",
userInfo: "",
oidc: {
issuer: config.issuer,
issuer: setDynamicParams(config.issuer, config),
},
}
}
Expand Down
13 changes: 12 additions & 1 deletion packages/core/src/oauth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <const T extends string, P extends Record<string, unknown>>(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))}`
})
}
Comment thread
halvaradop marked this conversation as resolved.

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,
Expand Down
8 changes: 8 additions & 0 deletions packages/core/src/shared/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -798,6 +799,13 @@ export const ERROR_CATALOG: Record<AuraErrorCode, CatalogEntry> = {
"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.",
},
Comment thread
halvaradop marked this conversation as resolved.
}

export interface AuraErrorOptions extends ErrorOptions {
Expand Down
20 changes: 20 additions & 0 deletions packages/core/test/actions/oidc/discovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
22 changes: 22 additions & 0 deletions packages/core/test/actions/oidc/resolve-provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})
145 changes: 144 additions & 1 deletion packages/core/test/oauth.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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",
},
})
})
})
2 changes: 1 addition & 1 deletion packages/elysia/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -104,4 +104,4 @@
"elysia": ">=1.0.0"
},
"packageManager": "pnpm@10.15.0"
}
}
2 changes: 1 addition & 1 deletion packages/express/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -109,4 +109,4 @@
"express": ">=4.0.0"
},
"packageManager": "pnpm@10.15.0"
}
}
2 changes: 1 addition & 1 deletion packages/hono/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -107,4 +107,4 @@
"hono": ">=4.0.0"
},
"packageManager": "pnpm@10.15.0"
}
}
2 changes: 1 addition & 1 deletion packages/next/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -142,4 +142,4 @@
"react-dom": ">=19.0.0"
},
"packageManager": "pnpm@10.15.0"
}
}
2 changes: 1 addition & 1 deletion packages/react-router/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -114,4 +114,4 @@
"react-router": ">=7.0.0"
},
"packageManager": "pnpm@10.15.0"
}
}
2 changes: 1 addition & 1 deletion packages/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -126,4 +126,4 @@
"@types/react": ">=19.0.0"
},
"packageManager": "pnpm@10.15.0"
}
}
Loading