@molecule/api-oauth-apple
Provider bond · auth · API (Node) · v1.0.3 · Apache-2.0
Sign in with Apple OAuth provider for molecule.dev.
npm install @molecule/api-oauth-applenpm · Source on GitHub · Implements @molecule/api-oauth
How it works
@molecule/api-oauth-apple is a provider bond on the API (Node) side: it implements the auth core interface (@molecule/api-oauth) with a concrete vendor or library behind it.
Your code calls the core; you wire this provider once at startup. Swapping vendors later is one line in that wiring, not a rewrite.
// npm install @molecule/api-oauth-apple --workspace=api
// Env: OAUTH_APPLE_CLIENT_ID (the Services ID), OAUTH_APPLE_TEAM_ID,
// OAUTH_APPLE_KEY_ID, OAUTH_APPLE_PRIVATE_KEY (.p8 contents) — server-only.
import { bond } from '@molecule/api-bond'
import { getAuthorizeUrl, serverName, verify } from '@molecule/api-oauth-apple'
// api/src/bonds/oauth-apple.ts, called from setupBonds() — the `oauth`
// category is NAMED: providers coexist, and the bond name IS the
// serverName ('apple').
bond('oauth', serverName, { serverName, verify, getAuthorizeUrl })
// Redirect: register the exact redirect URI(s) the app will use on the
// Services ID; the login button just links to GET /users/oauth/apple.Works with: @molecule/api-bond, @molecule/api-http, @molecule/api-oauth, @molecule/api-secrets
Secrets: OAUTH_APPLE_CLIENT_ID, OAUTH_APPLE_TEAM_ID, OAUTH_APPLE_KEY_ID, OAUTH_APPLE_PRIVATE_KEY
Reference
Sign in with Apple OAuth provider for molecule.dev.
Setup
-
Enroll in the Apple Developer Program.
-
In the Certificates, Identifiers & Profiles console, create:
- An App ID with the Sign in with Apple capability enabled.
- A Services ID whose identifier becomes your
OAUTH_APPLE_CLIENT_ID. Configure its Sign in with Apple settings with your domain and the exact redirect URI(s) you will use (e.g.https://yourapp.com/auth/apple/callback). - A Key with Sign in with Apple enabled. Download the resulting
.p8file once (Apple does not allow re-download). The 10-character Key ID becomesOAUTH_APPLE_KEY_ID; the file's contents becomeOAUTH_APPLE_PRIVATE_KEY.
-
Locate your Team ID at the top right of the developer console; set it to
OAUTH_APPLE_TEAM_ID. -
Configure your API environment:
OAUTH_APPLE_CLIENT_ID— the Services IDOAUTH_APPLE_TEAM_ID— your Apple Developer Team IDOAUTH_APPLE_KEY_ID— the 10-character Key IDOAUTH_APPLE_PRIVATE_KEY— the PKCS8 PEM contents of the.p8file (newlines may be encoded as\n)
-
Restart your API so it picks up the environment variables.
Your users should now be able to log in via Apple!
Auto-generated, AI-first package reference for the molecule.dev ecosystem. It is written to be read by coding agents as much as by people, and is generated from this package's source — edit
src/index.tsJSDoc, not this file.
Quick Start
// npm install @molecule/api-oauth-apple --workspace=api
// Env: OAUTH_APPLE_CLIENT_ID (the Services ID), OAUTH_APPLE_TEAM_ID,
// OAUTH_APPLE_KEY_ID, OAUTH_APPLE_PRIVATE_KEY (.p8 contents) — server-only.
import { bond } from '@molecule/api-bond'
import { getAuthorizeUrl, serverName, verify } from '@molecule/api-oauth-apple'
// api/src/bonds/oauth-apple.ts, called from setupBonds() — the `oauth`
// category is NAMED: providers coexist, and the bond name IS the
// serverName ('apple').
bond('oauth', serverName, { serverName, verify, getAuthorizeUrl })
// Redirect: register the exact redirect URI(s) the app will use on the
// Services ID; the login button just links to GET /users/oauth/apple.
Type
provider
Installation
npm install @molecule/api-oauth-apple @molecule/api-bond @molecule/api-http @molecule/api-oauth @molecule/api-secrets jsonwebtoken
npm install -D @types/jsonwebtoken
API
Interfaces
AppleClientSecretInput
Inputs required to mint an Apple client-secret JWT.
interface AppleClientSecretInput {
/** The Apple Developer Team ID (`iss` claim). */
teamId: string
/** The Apple Services ID / client ID (`sub` claim). */
clientId: string
/** The 10-character Apple Key ID (`kid` JWT header). */
keyId: string
/** PKCS8 PEM-encoded private key contents (the `.p8` file). */
privateKey: string
/** Optional override for token lifetime, in seconds. */
lifetimeSeconds?: number
}
AppleJwk
A single public-key entry as returned by Apple's JWKS endpoint.
interface AppleJwk {
kty: string
kid: string
use?: string
alg?: string
n: string
e: string
}
AppleIdTokenClaims
The decoded payload of an Apple ID token (a JWT issued by Apple at
https://appleid.apple.com). Apple does not expose a userinfo endpoint —
the ID token is the user info.
interface AppleIdTokenClaims {
/** Issuer — always `https://appleid.apple.com`. */
iss: string
/** Audience — the Apple Services ID (`OAUTH_APPLE_CLIENT_ID`). */
aud: string
/** Stable Apple-provided user identifier. */
sub: string
/** Issued-at timestamp (seconds since epoch). */
iat: number
/** Expiration timestamp (seconds since epoch). */
exp: number
/** Optional nonce echoed back from the authorization request. */
nonce?: string
/** Whether `nonce` was supplied. */
nonce_supported?: boolean
/** The user's email address, when permission was granted. */
email?: string
/** Whether Apple verified the email. Apple returns `'true'`/`'false'` strings or booleans. */
email_verified?: boolean | string
/** Whether the email is a private relay address Apple created for the user. */
is_private_email?: boolean | string
/** Real-user-status indicator (0=unsupported, 1=unknown, 2=likely real). */
real_user_status?: number
/** Indexable additional claims so callers may surface vendor extensions. */
[key: string]: unknown
}
AppleTokenResponse
Tokens returned by Apple's /auth/token endpoint.
interface AppleTokenResponse {
access_token: string
expires_in: number
id_token: string
refresh_token?: string
token_type: string
}
AppleAuthorizationUrlOptions
Options for getAuthorizationUrl.
interface AppleAuthorizationUrlOptions {
/** Opaque state token echoed back in the redirect (CSRF defence). */
state: string
/** Redirect URI — must exactly match a value registered in the Apple developer portal. */
redirectUri: string
/** Space-separated list of scopes. Defaults to `'name email'`. */
scope?: string
/** Optional nonce for replay protection. */
nonce?: string
/** Response mode (defaults to `form_post`, required when `scope` includes `name` or `email`). */
responseMode?: 'query' | 'fragment' | 'form_post'
}
OAuthUserProps
The properties returned when verifying an OAuth code.
interface OAuthUserProps {
/**
* An alphanumeric username derived from the OAuth provider.
*
* Format: `{provider_username}@{provider_name}`
*/
username: string
/**
* The user's display name from the OAuth provider.
*/
name?: string
/**
* The user's short biography / description from the OAuth provider
* (e.g. GitHub's `bio`, GitLab's `bio`, X's `description`). Omitted when
* the provider exposes no such field.
*/
bio?: string
/**
* URL of the user's profile image from the OAuth provider (e.g. Google's
* `picture`, GitHub/GitLab's `avatar_url`, X's `profile_image_url`).
* Omitted when the provider exposes none (Apple never does; Microsoft
* Graph only serves photos as a binary endpoint behind an extra scope).
*/
avatar?: string
/**
* The user's email address from the OAuth provider.
*/
email?: string
/**
* Whether the OAuth provider has affirmatively verified that the user
* controls this `email` mailbox.
*
* `true` MUST mean the provider proved mailbox ownership (e.g. Google's
* `email_verified`, Apple's `email_verified` ID-token claim). When the
* provider exposes no trustworthy verification signal in the profile data
* the verifier fetched, this MUST be `false`/`undefined` (never optimistically
* `true`) — consumers treat only an explicit `true` as verified.
*
* Consumers (e.g. the user resource's `logInOAuth` handler) use this to
* decide whether a provider-supplied email may be trusted over an existing,
* unverified local account — preventing an unverified squatter from blocking
* the verified mailbox owner.
*/
emailVerified?: boolean
/**
* The OAuth server identifier (e.g., 'github', 'google', 'twitter').
*/
oauthServer: string
/**
* Unique identifier for the user from the OAuth provider.
*/
oauthId: string
/**
* Raw user data from the OAuth provider.
*/
oauthData: Record<string, unknown>
}
OAuthAuthorizeUrlParams
Parameters for building a provider authorization (initiation) URL — the URL the user's browser is redirected to so the provider can authenticate them and send back an authorization code.
interface OAuthAuthorizeUrlParams {
/**
* Absolute URI the provider should redirect the user back to after
* authorization (the app origin, optionally with a path). When omitted,
* the builder leaves `redirect_uri` off the URL so the provider falls
* back to its registered callback URL.
*/
redirectUri?: string
/**
* The CSRF `state` parameter bound to the initiating session (stored in
* an httpOnly cookie by the initiation endpoint and validated by the
* login handler on callback).
*/
state: string
/**
* PKCE code challenge derived (S256) from the per-session code verifier.
* Omit only for providers that do not support PKCE.
*/
codeChallenge?: string
/**
* PKCE challenge method. Always prefer `'S256'`; `'plain'` exists only
* for providers that cannot hash.
*/
codeChallengeMethod?: 'S256' | 'plain'
}
Types
OAuthVerifier
Exchanges an OAuth authorization code for user profile information.
Implementations call the provider's token and user-info endpoints,
then return normalized OAuthUserProps for account creation or login.
Returning null means the provider AFFIRMATIVELY rejected the code
(e.g. GitHub's bad_verification_code, an expired/forged code) — the
consumer (logInOAuth) surfaces that as a clean 403 "verification
failed". A thrown error means an infrastructure failure (network,
provider outage) and surfaces as a 500. Implementations MUST NOT throw
for a rejected code — that would misreport a client mistake (or an
attack) as a server fault.
type OAuthVerifier = (
code: string,
codeVerifier?: string,
redirectUri?: string,
) => Promise<OAuthUserProps | null>
code— The authorization code received from the OAuth redirect.codeVerifier— The PKCE code verifier, if the flow uses proof-key exchange.redirectUri— The redirect URI that was used in the authorization request.
OAuthAuthorizeUrlBuilder
Builds the provider's authorization URL for OAuth initiation
(GET /users/oauth/:provider → 302 to this URL). Implementations embed
their own client id, scopes, and authorize endpoint so no consumer ever
hardcodes provider knowledge.
type OAuthAuthorizeUrlBuilder = (params: OAuthAuthorizeUrlParams) => string | null
params— State, PKCE challenge, and optional redirect URI.
Returns: The absolute authorization URL, or null when the provider is not configured (e.g. its client id env var is unset).
Functions
getAuthorizationUrl(options)
Returns the URL to which the user agent should be redirected to start the Sign-in-with-Apple flow.
response_mode=form_post is the default because Apple only returns the
name and email scopes via form-post callbacks.
function getAuthorizationUrl({
state,
redirectUri,
scope = DEFAULT_APPLE_SCOPE,
nonce,
responseMode,
}: AppleAuthorizationUrlOptions): string
options— State, redirect URI, and optional scope/nonce/response mode.
Returns: The fully-constructed authorization URL.
getAuthorizeUrl(params)
Builds the Sign-in-with-Apple authorization URL for OAuth initiation
(GET /users/oauth/:provider 302s the browser here). Embeds this bond's
client id and default scopes (name email) plus the caller's CSRF
state, so no consumer hardcodes Apple knowledge.
redirect_uri resolves from params.redirectUri falling back to
APP_ORIGIN (the same fallback the token exchange uses) — Apple requires
an exact registered redirect_uri and has no registered-fallback
behavior, so the param is set whenever one resolves.
response_mode=form_post is required by Apple for the name/email
scopes: the callback arrives as an HTTP POST with code/state in the
form body, so redirect_uri must point at a server-side receiver that
forwards them into POST /users/log-in/oauth (see the module JSDoc).
Apple does not support PKCE: the codeChallenge/codeChallengeMethod
params are accepted (the shared initiation handler always sends them) but
deliberately not emitted — form_post keeping the code out of the URL is
the mitigation Apple recommends.
function getAuthorizeUrl({
redirectUri,
state,
// Apple's authorize endpoint does not support PKCE — accept the
// code_challenge/code_challenge_method params from the shared contract
// (the initiation handler always sends them) but deliberately do NOT
// emit them. `response_mode=form_post` (code delivered in a POST body,
// never in the URL) is the mitigation Apple recommends instead.
codeChallenge: _codeChallenge,
codeChallengeMethod: _codeChallengeMethod,
}: OAuthAuthorizeUrlParams): string | null
params— State, PKCE challenge (accepted but unused), and optional redirect URI.
Returns: The Apple authorize URL, or null when OAUTH_APPLE_CLIENT_ID is unset.
createAppleClientSecret(input)
Signs an Apple client-secret JWT (ES256) suitable for use as the
client_secret parameter in /auth/token requests.
Note: this function intentionally does not include the private key in any thrown error.
function createAppleClientSecret({
teamId,
clientId,
keyId,
privateKey,
lifetimeSeconds = APPLE_CLIENT_SECRET_DEFAULT_LIFETIME_SECONDS,
}: AppleClientSecretInput): string
input— Team ID, client ID, key ID, and private key.
Returns: The compact-serialized JWT.
resetJwksCache()
Reset the JWKS cache. Exposed for tests; production code should not call this.
function resetJwksCache(): void
getAppleJwks()
Fetch Apple's JWKS, returning a kid → JWK map. Cached for JWKS_CACHE_TTL_MS.
function getAppleJwks(): Promise<Map<string, AppleJwk>>
Returns: A map of kid to JWK suitable for verifying Apple ID tokens.
jwkToPem(jwk)
Convert a JWK to a PEM-encoded SPKI public key string suitable for
passing to jsonwebtoken.verify with algorithm RS256.
function jwkToPem(jwk: AppleJwk): string
jwk— The JWK to convert.
Returns: A PEM-encoded SPKI public key.
exchangeCodeForTokens(code, redirectUri)
Exchanges an authorization code for Apple-issued tokens.
function exchangeCodeForTokens(code: string, redirectUri?: string): Promise<AppleTokenResponse>
code— The authorization code from the OAuth callback.redirectUri— The redirect URI used in the authorization request. Defaults toAPP_ORIGIN.
Returns: The token-exchange response, including id_token and (typically) a refresh_token.
refreshAccessToken(refreshToken)
Exchanges a refresh token for a fresh access token (and possibly a new id_token).
function refreshAccessToken(refreshToken: string): Promise<AppleTokenResponse>
refreshToken— The refresh token previously issued by Apple.
Returns: The token-refresh response.
verifyIdToken(idToken)
Verifies an Apple ID token. Fetches Apple's JWKS, locates the key
matching the token's kid, verifies the RS256 signature, then asserts
iss === 'https://appleid.apple.com', aud === OAUTH_APPLE_CLIENT_ID,
and that the token has not expired.
function verifyIdToken(idToken: string): Promise<AppleIdTokenClaims>
idToken— The compact-serialized JWT to verify.
Returns: The decoded and validated AppleIdTokenClaims.
getUserInfo(idToken)
Apple does not expose a userinfo endpoint — the ID token is the user info. This helper takes an Apple-issued ID token, verifies its signature/issuer/audience/expiry, and returns the decoded claims.
function getUserInfo(idToken: string): Promise<AppleIdTokenClaims>
idToken— The Apple-issued ID token (JWT).
Returns: The verified AppleIdTokenClaims.
verify(code, _codeVerifier, redirectUri)
Verifies an Apple OAuth authorization code and returns normalized
OAuthUserProps, or null when Apple affirmatively rejects the code
(HTTP 400 invalid_grant from /auth/token — invalid/expired/reused) so
the consumer surfaces a clean 403 instead of a misleading 500.
Note: Apple only includes the user's name in the initial form-post
callback (not in the ID token), so callers wanting display-name capture
must persist that value separately at the redirect-handler layer.
function verify(
code: string,
_codeVerifier?: string,
redirectUri?: string,
): Promise<{
username: string
email: string | undefined
emailVerified: boolean
oauthServer: 'apple'
oauthId: string
oauthData: {
[key: string]: unknown
iss: string
aud: string
sub: string
iat: number
exp: number
nonce?: string
nonce_supported?: boolean
email?: string
email_verified?: boolean | string
is_private_email?: boolean | string
real_user_status?: number
}
} | null>
code— The authorization code from the OAuth callback._codeVerifier— Unused; included forOAuthVerifiersignature compatibility.redirectUri— The redirect URI used in the authorization request.
Returns: Normalized OAuth user props, or null when Apple rejected the code.
Constants
APPLE_AUTHORIZATION_URL
Apple authorization endpoint.
const APPLE_AUTHORIZATION_URL: 'https://appleid.apple.com/auth/authorize'
DEFAULT_APPLE_SCOPE
Default scope when callers do not specify one.
const DEFAULT_APPLE_SCOPE: 'name email'
APPLE_CLIENT_SECRET_MAX_LIFETIME_SECONDS
Maximum lifetime Apple permits for a client-secret JWT (6 months in seconds).
const APPLE_CLIENT_SECRET_MAX_LIFETIME_SECONDS: 15777000
APPLE_CLIENT_SECRET_DEFAULT_LIFETIME_SECONDS
Default client-secret JWT lifetime in seconds (5 minutes).
const APPLE_CLIENT_SECRET_DEFAULT_LIFETIME_SECONDS: 300
APPLE_JWKS_URL
Apple JWKS endpoint URL.
const APPLE_JWKS_URL: 'https://appleid.apple.com/auth/keys'
JWKS_CACHE_TTL_MS
TTL for the cached JWKS, in milliseconds (1 hour).
const JWKS_CACHE_TTL_MS: number
oauthAppleSecretDefinitions
Secret definitions required by the Sign in with Apple OAuth bond.
const oauthAppleSecretDefinitions: SecretDefinition[]
APPLE_TOKEN_URL
Apple token endpoint.
const APPLE_TOKEN_URL: 'https://appleid.apple.com/auth/token'
APPLE_ID_TOKEN_ISSUER
The expected iss claim for Apple-issued ID tokens.
const APPLE_ID_TOKEN_ISSUER: 'https://appleid.apple.com'
serverName
The OAuth server identifier for Apple.
const serverName: 'apple'
Core Interface
Implements @molecule/api-oauth interface.
Bond Wiring
Setup function to register this provider with the bond system:
import { bond } from '@molecule/api-bond'
import { serverName, verify, getAuthorizeUrl } from '@molecule/api-oauth-apple'
export function setupOauthApple(): void {
bond('oauth', serverName, { serverName, verify, getAuthorizeUrl })
}
Injection Notes
Requirements
Peer dependencies:
@molecule/api-bond^1.0.1@molecule/api-http^1.0.1@molecule/api-oauth^1.0.1@molecule/api-secrets^1.0.1
Environment Variables
OAUTH_APPLE_CLIENT_ID(required) — Apple Services ID- Setup: Apple Developer → Identifiers → create a Services ID with "Sign in with Apple" enabled.
- Get it here: https://developer.apple.com/account/resources/identifiers/list/serviceId
- Example:
com.example.app.signin
OAUTH_APPLE_TEAM_ID(required) — Apple Team ID- Setup: The 10-character Team ID from your Apple Developer membership details.
- Get it here: https://developer.apple.com/account
- Example:
A1B2C3D4E5
OAUTH_APPLE_KEY_ID(required) — Apple key ID- Setup: Create a "Sign in with Apple" key under Certificates, Identifiers & Profiles → Keys.
- Get it here: https://developer.apple.com/account/resources/authkeys/list
OAUTH_APPLE_PRIVATE_KEY(required) — Apple private key (.p8)- Setup: Download the .p8 file when creating the key (one-time download) and paste its PEM contents.
- Get it here: https://developer.apple.com/account/resources/authkeys/list
- Example:
contents of AuthKey_ABC123DEF4.p8
Runtime Dependencies
@molecule/api-bond@molecule/api-http@molecule/api-oauth@molecule/api-secretsjsonwebtoken
E2E Tests
Integration checklist — drive the real UI (live preview, no mocks), adapt each item to this app's actual screens/flows, and check every box off one by one. A box you can't check is an integration bug to fix — not a skip:
- Clicking the app's "Sign in with {provider}" button (Google, GitHub, …) redirects to the provider's authorize URL carrying the correct client_id, the app's requested scopes, AND the app's registered redirect_uri — inspect the actual outbound URL (the 302 Location, or the address the popup/tab navigates to) and confirm each value; a missing or wrong one is the bug.
- The callback route exchanges the returned code SERVER-SIDE for a token, fetches the profile, and creates-or-links the app user + establishes a session — after the round-trip the app shows that user logged in. CAVEAT: the provider's own consent screen runs on ITS domain and CANNOT be driven in the sandbox, so verify the two boundaries you DO own — the authorize URL going out (above) and the callback coming back — not the provider's page. Complete the round-trip with a test/stub provider bond if one is wired; otherwise assert the callback handler's own behavior (state check → code exchange → user create-or-link → session). Never mock the flow or edit production code to bypass the provider.
- A returning OAuth user logs into the SAME account — sign in twice and confirm one user row linked by provider id (oauthServer + oauthId), not a fresh duplicate created each time.
- SECURITY — the
stateparameter is generated on initiation and verified on callback (CSRF protection): a mismatched or absentstateis rejected (403); theredirect_uriis validated against an allowlist so an attacker cannot redirect the code elsewhere; and the client secret + tokens stay server-side — grep the browser bundle and network tab to confirm the secret never reaches the client (only the authorize URL and returned code cross the boundary).