Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/nasty-deers-cover.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@aryalabs/openauth": minor
---

support auth code flow with OIDC providers
5 changes: 5 additions & 0 deletions .changeset/swift-pears-matter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@aryalabs/openauth": minor
---

support iOS AuthenticationServices SIWA flow with Apple OIDC provider
102 changes: 60 additions & 42 deletions packages/openauth/src/issuer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>,
})
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<string, string>,
})
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")
Expand Down
63 changes: 61 additions & 2 deletions packages/openauth/src/provider/apple.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
/**
Expand Down Expand Up @@ -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
}
139 changes: 135 additions & 4 deletions packages/openauth/src/provider/oidc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,42 @@ export interface OidcConfig {
* ```
*/
query?: Record<string, string>
/**
* 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"
}

/**
Expand All @@ -99,11 +135,28 @@ export interface IdTokenResponse {
raw: Record<string, any>
}

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(
Expand Down Expand Up @@ -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)
Expand All @@ -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<string, string> = {
"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,
Expand Down
Loading