diff --git a/.changeset/nasty-deers-cover.md b/.changeset/nasty-deers-cover.md new file mode 100644 index 00000000..ffe583ff --- /dev/null +++ b/.changeset/nasty-deers-cover.md @@ -0,0 +1,5 @@ +--- +"@aryalabs/openauth": minor +--- + +support auth code flow with OIDC providers diff --git a/.changeset/swift-pears-matter.md b/.changeset/swift-pears-matter.md new file mode 100644 index 00000000..66665d2f --- /dev/null +++ b/.changeset/swift-pears-matter.md @@ -0,0 +1,5 @@ +--- +"@aryalabs/openauth": minor +--- + +support iOS AuthenticationServices SIWA flow with Apple OIDC provider diff --git a/packages/openauth/src/issuer.ts b/packages/openauth/src/issuer.ts index f4c1f277..88ab49e7 100644 --- a/packages/openauth/src/issuer.ts +++ b/packages/openauth/src/issuer.ts @@ -955,54 +955,72 @@ export function issuer< } if (grantType === "client_credentials") { - const provider = form.get("provider") + const provider = form.get("provider")?.toString(); if (!provider) return c.json({ error: "missing `provider` form value" }, 400) - const match = input.providers[provider.toString()] - if (!match) - return c.json({ error: "invalid `provider` query parameter" }, 400) - if (!match.client) + const match = input.providers[provider]; + if (!match) { return c.json( - { error: "this provider does not support client_credentials" }, + { error: "invalid_request", error_description: `Invalid provider: ${provider}` }, 400, - ) - const clientID = form.get("client_id") - const clientSecret = form.get("client_secret") - if (!clientID) + ); + } + if (!match.client) { + return c.json( + { error: "unsupported_grant_type", error_description: `Provider "${provider}" does not support client_credentials` }, + 400, + ); + } + + const clientID = form.get("client_id")?.toString(); + const clientSecret = form.get("client_secret")?.toString(); + + if (!clientID) { return c.json({ error: "missing `client_id` form value" }, 400) - if (!clientSecret) - return c.json({ error: "missing `client_secret` form value" }, 400) - const response = await match.client({ - clientID: clientID.toString(), - clientSecret: clientSecret.toString(), - params: Object.fromEntries(form) as Record, - }) - return input.success( - { - async subject(type, properties, opts) { - const tokens = await generateTokens(c, { - type: type as string, - subject: - opts?.subject || (await resolveSubject(type, properties)), - properties, - clientID: clientID.toString(), - ttl: { - access: opts?.ttl?.access ?? ttlAccess, - refresh: opts?.ttl?.refresh ?? ttlRefresh, - }, - }) - return c.json({ - access_token: tokens.access, - refresh_token: tokens.refresh, - }) + } + + try { + const response = await match.client({ + clientID: clientID, + clientSecret: clientSecret, + params: Object.fromEntries(form) as Record, + }) + return input.success( + { + async subject(type, properties, opts) { + const tokens = await generateTokens(c, { + type: type as string, + subject: + opts?.subject || (await resolveSubject(type, properties)), + properties, + clientID: clientID, + ttl: { + access: opts?.ttl?.access ?? ttlAccess, + refresh: opts?.ttl?.refresh ?? ttlRefresh, + }, + }); + return c.json({ + access_token: tokens.access, + refresh_token: tokens.refresh, + }) + }, }, - }, - { - provider: provider.toString(), - ...response, - }, - c.req.raw, - ) + { + provider: provider, + ...response, + }, + c.req.raw, + ); + } catch (err: any) { + // Handle errors from the provider's client function + const oauthError = err instanceof OauthError + ? err + : new OauthError("invalid_grant", err.message || "Client validation failed"); + return c.json( + { error: oauthError.error, error_description: oauthError.description }, + 400, + ); + } } throw new Error("Invalid grant_type") diff --git a/packages/openauth/src/provider/apple.ts b/packages/openauth/src/provider/apple.ts index f69e252b..f35356b3 100644 --- a/packages/openauth/src/provider/apple.ts +++ b/packages/openauth/src/provider/apple.ts @@ -53,6 +53,8 @@ import { Oauth2Provider, Oauth2WrappedConfig } from "./oauth2.js" import { OidcProvider, OidcWrappedConfig } from "./oidc.js" +import { createRemoteJWKSet, jwtVerify } from "jose" +import { OauthError } from "../error.js" export interface AppleConfig extends Oauth2WrappedConfig { /** @@ -109,19 +111,76 @@ export function AppleProvider(config: AppleConfig) { * Create an Apple OIDC provider. * * This is useful if you just want to verify the user's email address. + * Includes support for client_credentials flow using Apple ID Token. * * @param config - The config for the provider. * @example * ```ts * AppleOidcProvider({ - * clientID: "1234567890" + * clientID: "1234567890", + * clientSecret: "your-client-secret" // Required for Apple OIDC * }) * ``` */ export function AppleOidcProvider(config: AppleOidcConfig) { - return OidcProvider({ + const baseProvider = OidcProvider({ ...config, type: "apple" as const, issuer: "https://appleid.apple.com", + responseType: "code", + tokenEndpointAuthMethod: "client_secret_post", }) + + baseProvider.client = async ({ clientID, params }) => { + if (clientID !== config.clientID) { + throw new OauthError("unauthorized_client", "Client ID mismatch.") + } + if (!config.clientSecret) { + throw new OauthError("server_error", "Provider configuration missing clientSecret.") + } + + const idToken = params.id_token + const appId = params.app_id + + if (!idToken) { + throw new OauthError("invalid_request", "Missing required parameter: id_token") + } + if (!appId) { + throw new OauthError("invalid_request", "Missing required parameter: app_id") + } + + try { + const jwksUrl = new URL(`https://appleid.apple.com/auth/keys`) as any + const jwks = createRemoteJWKSet(jwksUrl) + + const { payload } = await jwtVerify(idToken, jwks, { + issuer: "https://appleid.apple.com", + audience: appId, + }) + + const email = payload.email as string + const isEmailVerified = payload.email_verified === true || String(payload.email_verified) === 'true' + + if (!email) { + throw new OauthError("invalid_grant", "Email not found in Apple ID token. User might have used private relay without granting email access.") + } + if (!isEmailVerified) { + console.warn(`Apple email (${email}) is not verified. Proceeding, but verification recommended.`) + } + + return { + id: { + email: email, + email_verified: isEmailVerified, + sub: payload.sub, + }, + clientID: clientID + } + + } catch (error: any) { + throw new OauthError("server_error", "Apple ID token verification failed.") + } + } + + return baseProvider } diff --git a/packages/openauth/src/provider/oidc.ts b/packages/openauth/src/provider/oidc.ts index 8e21fde7..cb9a023a 100644 --- a/packages/openauth/src/provider/oidc.ts +++ b/packages/openauth/src/provider/oidc.ts @@ -77,6 +77,42 @@ export interface OidcConfig { * ``` */ query?: Record + /** + * The response type to use for the OIDC flow. + * Use "id_token" for implicit flow or "code" for authorization code flow. + * Defaults to "id_token". + * + * @example + * ```ts + * { + * responseType: "code" + * } + * ``` + */ + responseType?: "id_token" | "code" + /** + * The client secret, required for authorization code flow. + * + * @example + * ```ts + * { + * clientSecret: "your-client-secret" + * } + * ``` + */ + clientSecret?: string + /** + * The token endpoint authentication method. + * Defaults to "client_secret_post". + * + * @example + * ```ts + * { + * tokenEndpointAuthMethod: "client_secret_basic" + * } + * ``` + */ + tokenEndpointAuthMethod?: "client_secret_post" | "client_secret_basic" } /** @@ -99,11 +135,28 @@ export interface IdTokenResponse { raw: Record } +interface TokenResponse { + id_token: string + access_token?: string + token_type?: string + expires_in?: number + refresh_token?: string + [key: string]: unknown +} + +interface ErrorResponse { + error?: string + error_description?: string + [key: string]: unknown +} + export function OidcProvider( config: OidcConfig, ): Provider<{ id: JWTPayload; clientID: string }> { const query = config.query || {} const scopes = config.scopes || [] + const responseType = config.responseType || "id_token" + const tokenEndpointAuthMethod = config.tokenEndpointAuthMethod || "client_secret_post" const wk = lazy(() => fetch(config.issuer + "/.well-known/openid-configuration").then( @@ -138,7 +191,7 @@ export function OidcProvider( await wk().then((r) => r.authorization_endpoint), ) authorization.searchParams.set("client_id", config.clientID) - authorization.searchParams.set("response_type", "id_token") + authorization.searchParams.set("response_type", responseType) authorization.searchParams.set("response_mode", "form_post") authorization.searchParams.set("state", provider.state) authorization.searchParams.set("nonce", provider.nonce) @@ -160,14 +213,92 @@ export function OidcProvider( error.toString() as any, body.get("error_description")?.toString() || "", ) + + // handle authorization code flow + const code = body.get("code") + if (code) { + if (!config.clientSecret) { + throw new OauthError( + "invalid_request" as const, + "Client secret is required for code flow" + ) + } + + const tokenEndpoint = await wk().then((r) => r.token_endpoint) + const formData = new URLSearchParams() + formData.append("grant_type", "authorization_code") + formData.append("code", code.toString()) + formData.append("redirect_uri", provider.redirect) + + const headers: Record = { + "Content-Type": "application/x-www-form-urlencoded", + } + + if (tokenEndpointAuthMethod === "client_secret_post") { + formData.append("client_id", config.clientID) + formData.append("client_secret", config.clientSecret) + } else if (tokenEndpointAuthMethod === "client_secret_basic") { + const credentials = btoa(`${config.clientID}:${config.clientSecret}`) + headers["Authorization"] = `Basic ${credentials}` + } + + const response = await fetch(tokenEndpoint, { + method: "POST", + headers, + body: formData, + }) + + if (!response.ok) { + const errorData = await response.json() as ErrorResponse + throw new OauthError( + (errorData.error as any) || "server_error", + errorData.error_description || "Failed to exchange code for token" + ) + } + + const tokenResponse = await response.json() as TokenResponse + const idToken = tokenResponse.id_token + + if (!idToken) { + throw new OauthError( + "invalid_request" as const, + "Missing id_token in token response" + ) + } + + const result = await jwtVerify(idToken, await jwks(), { + audience: config.clientID, + }) + + if (result.payload.nonce !== provider.nonce) { + throw new OauthError( + "invalid_request" as const, + "Invalid nonce" + ) + } + + return ctx.success(c, { + id: result.payload, + clientID: config.clientID, + }) + } + + // handle implicit flow const idToken = body.get("id_token") - if (!idToken) - throw new OauthError("invalid_request", "Missing id_token") + if (!idToken) { + throw new OauthError( + "invalid_request" as const, + "Missing id_token or code" + ) + } const result = await jwtVerify(idToken.toString(), await jwks(), { audience: config.clientID, }) if (result.payload.nonce !== provider.nonce) { - throw new OauthError("invalid_request", "Invalid nonce") + throw new OauthError( + "invalid_request" as const, + "Invalid nonce" + ) } return ctx.success(c, { id: result.payload, diff --git a/packages/openauth/src/provider/provider.ts b/packages/openauth/src/provider/provider.ts index edbf6933..5bc623ec 100644 --- a/packages/openauth/src/provider/provider.ts +++ b/packages/openauth/src/provider/provider.ts @@ -8,7 +8,7 @@ export interface Provider { init: (route: ProviderRoute, options: ProviderOptions) => void client?: (input: { clientID: string - clientSecret: string + clientSecret?: string, params: Record }) => Promise }