Skip to content

Commit

Permalink
feat: add apple provider (#328)
Browse files Browse the repository at this point in the history
  • Loading branch information
GreenmeisterDavid authored Jan 27, 2025
1 parent 9d191a1 commit 4e9e5a9
Show file tree
Hide file tree
Showing 11 changed files with 326 additions and 416 deletions.
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ Add Authentication to Nuxt applications with secured & sealed cookies sessions.
## Features

- [Hybrid Rendering](#hybrid-rendering) support (SSR / CSR / SWR / Prerendering)
- [20+ OAuth Providers](#supported-oauth-providers)
- [30+ OAuth Providers](#supported-oauth-providers)
- [Password Hashing](#password-hashing)
- [WebAuthn (passkey)](#webauthn-passkey)
- [`useUserSession()` Vue composable](#vue-composable)
Expand Down Expand Up @@ -205,6 +205,7 @@ It can also be set using environment variables:
#### Supported OAuth Providers

- Apple
- Atlassian
- Auth0
- Authentik
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
"defu": "^6.1.4",
"h3": "^1.14.0",
"hookable": "^5.5.3",
"jose": "^5.9.6",
"ofetch": "^1.4.1",
"ohash": "^1.1.4",
"openid-client": "^6.1.7",
Expand Down
6 changes: 6 additions & 0 deletions playground/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -109,3 +109,9 @@ NUXT_OAUTH_ATLASSIAN_REDIRECT_URL=
NUXT_OAUTH_LINE_CLIENT_ID=
NUXT_OAUTH_LINE_CLIENT_SECRET=
NUXT_OAUTH_LINE_REDIRECT_URL=
# Apple
NUXT_OAUTH_APPLE_PRIVATE_KEY=
NUXT_OAUTH_APPLE_KEY_ID=
NUXT_OAUTH_APPLE_TEAM_ID=
NUXT_OAUTH_APPLE_CLIENT_ID=
NUXT_OAUTH_APPLE_REDIRECT_URL=
6 changes: 6 additions & 0 deletions playground/app.vue
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,12 @@ const providers = computed(() =>
disabled: Boolean(user.value?.atlassian),
icon: 'i-simple-icons-atlassian',
},
{
label: user.value?.apple || 'Apple',
to: '/auth/apple',
disabled: Boolean(user.value?.apple),
icon: 'i-simple-icons-apple',
},
].map(p => ({
...p,
prefetch: false,
Expand Down
1 change: 1 addition & 0 deletions playground/auth.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ declare module '#auth-utils' {
strava?: string
hubspot?: string
atlassian?: string
apple?: string
}

interface UserSession {
Expand Down
16 changes: 16 additions & 0 deletions playground/server/routes/auth/apple.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export default defineOAuthAppleEventHandler({
async onSuccess(event, { user, tokens }) {
const userToSet = user?.name?.firstName && user?.name?.lastName
? `${user.name.firstName} ${user.name.lastName}`
: user?.name?.firstName || user?.name?.lastName || tokens.email || tokens.sub

await setUserSession(event, {
user: {
apple: userToSet,
},
loggedInAt: Date.now(),
})

return sendRedirect(event, '/')
},
})
460 changes: 46 additions & 414 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -370,5 +370,13 @@ export default defineNuxtModule<ModuleOptions>({
clientSecret: '',
redirectURL: '',
})
// Apple OAuth
runtimeConfig.oauth.apple = defu(runtimeConfig.oauth.apple, {
teamId: '',
keyId: '',
privateKey: '',
redirectURL: '',
clientId: '',
})
},
})
181 changes: 181 additions & 0 deletions src/runtime/server/lib/oauth/apple.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import type { H3Event } from 'h3'
import { eventHandler, getRequestHeader, readBody, sendRedirect } from 'h3'
import { withQuery } from 'ufo'
import { defu } from 'defu'
import { handleMissingConfiguration, handleAccessTokenErrorResponse, getOAuthRedirectURL, requestAccessToken, signJwt, verifyJwt } from '../utils'
import { useRuntimeConfig } from '#imports'
import type { OAuthConfig } from '#auth-utils'

export interface OAuthAppleConfig {
/**
* Apple OAuth Client ID
* @default process.env.NUXT_OAUTH_APPLE_CLIENT_ID
*/
clientId?: string

/**
* Apple OAuth team ID
* @default process.env.NUXT_OAUTH_APPLE_TEAM_ID
*/
teamId?: string

/**
* Apple OAuth key identifier
* @default process.env.NUXT_OAUTH_APPLE_KEY_ID
*/
keyId?: string

/**
* Apple OAuth Private Key. Linebreaks must be replaced with \n
* @example '-----BEGIN PRIVATE KEY-----\nMIGTAgEAMBMGByqGSM...\n-----END PRIVATE KEY-----'
* @default process.env.NUXT_OAUTH_APPLE_PRIVATE_KEY
*/
privateKey?: string

/**
* Apple OAuth Scope. Apple wants this to be a string separated by spaces, but for consistency with other providers, we also allow an array of strings.
* @default ''
* @see https://developer.apple.com/documentation/sign_in_with_apple/clientconfigi/3230955-scope
* @example 'name email'
*/
scope?: string | string[]

/**
* Apple OAuth Authorization URL
* @default 'https://appleid.apple.com/auth/authorize'
*/
authorizationURL?: string

/**
* Extra authorization parameters to provide to the authorization URL
* @see https://developer.apple.com/documentation/sign_in_with_apple/clientconfigi/3230955-scope
* @example { usePop: true }
*/
authorizationParams?: Record<string, string | boolean>

/**
* Apple OAuth Token URL
* @default 'https://appleid.apple.com/auth/token'
*/
tokenURL?: string

/**
* Redirect URL to to allow overriding for situations like prod failing to determine public hostname
* @default process.env.NUXT_OAUTH_APPLE_REDIRECT_URL or current URL
*/
redirectURL?: string
}

export interface OAuthAppleTokens {
iss: string
aud: string
exp: number
iat: number
sub: string
at_hash: string
email: string
email_verified: boolean
is_private_email: boolean
auth_time: number
nonce_supported: boolean
}

export interface OAuthAppleUser {
name?: {
firstName?: string
lastName?: string
}
email?: string
}

export function defineOAuthAppleEventHandler({
config,
onSuccess,
onError,
}: OAuthConfig<OAuthAppleConfig>) {
return eventHandler(async (event: H3Event) => {
config = defu(config, useRuntimeConfig(event).oauth?.apple, {
authorizationURL: config?.authorizationURL || 'https://appleid.apple.com/auth/authorize',
authorizationParams: {},
}) as OAuthAppleConfig

if (!config.teamId || !config.keyId || !config.privateKey || !config.clientId) {
return handleMissingConfiguration(event, 'apple', ['teamId', 'keyId', 'privateKey', 'clientId'], onError)
}

// instead of a query, apple sends a form post back after login
const isPost = getRequestHeader(event, 'content-type') === 'application/x-www-form-urlencoded'

let code: string | undefined
let user: OAuthAppleUser | undefined

if (isPost) {
// `user` will only be available the first time a user logs in.
({ code, user } = await readBody<{ code: string, user?: OAuthAppleUser }>(event))
}

// Send user to apple login page.
if (!isPost || !code) {
const redirectURL = config.redirectURL || getOAuthRedirectURL(event)

config.scope = Array.isArray(config.scope)
? config.scope.join(' ')
: (config.scope || 'name email')

return sendRedirect(
event,
withQuery(config.authorizationURL as string, {
response_type: 'code',
response_mode: 'form_post',
client_id: config.clientId,
redirect_uri: redirectURL,
scope: config.scope,
...config.authorizationParams,
}),
)
}

// Verify the form post data we got back from apple
try {
const secret = await signJwt(
{
iss: config.teamId,
aud: 'https://appleid.apple.com',
sub: config.clientId,
},
{
privateKey: config.privateKey,
keyId: config.keyId,
teamId: config.teamId,
clientId: config.clientId,
expiresIn: '5m',
},
)

const accessTokenResult = await requestAccessToken(config.tokenURL || 'https://appleid.apple.com/auth/token', {
params: {
client_id: config.clientId,
client_secret: secret,
code,
grant_type: 'authorization_code',
redirect_uri: config.redirectURL,
},
})

const tokens = await verifyJwt<OAuthAppleTokens>(accessTokenResult.id_token, {
publicJwkUrl: 'https://appleid.apple.com/auth/keys',
audience: config.clientId,
issuer: 'https://appleid.apple.com',
})

if (!tokens) {
return handleAccessTokenErrorResponse(event, 'apple', tokens, onError)
}

return onSuccess(event, { user, tokens })
}
catch (error) {
return handleAccessTokenErrorResponse(event, 'apple', error, onError)
}
})
}
58 changes: 58 additions & 0 deletions src/runtime/server/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { H3Event } from 'h3'
import { getRequestURL } from 'h3'
import { FetchError } from 'ofetch'
import { snakeCase, upperFirst } from 'scule'
import * as jose from 'jose'
import type { OAuthProvider, OnError } from '#auth-utils'
import { createError } from '#imports'

Expand Down Expand Up @@ -98,3 +99,60 @@ export function handleMissingConfiguration(event: H3Event, provider: OAuthProvid
if (!onError) throw error
return onError(event, error)
}

/**
* JWT signing using jose
*
* @see https://github.com/panva/jose
*/

interface JWTSignOptions {
privateKey: string
keyId: string
teamId?: string
clientId?: string
algorithm?: 'ES256' | 'RS256'
expiresIn?: string // e.g., '5m', '1h'
}

export async function signJwt<T extends Record<string, unknown>>(
payload: T,
options: JWTSignOptions,
): Promise<string> {
const now = Math.floor(Date.now() / 1000)
const privateKey = await jose.importPKCS8(
options.privateKey.replace(/\\n/g, '\n'),
options.algorithm || 'ES256',
)

return new jose.SignJWT(payload)
.setProtectedHeader({ alg: options.algorithm || 'ES256', kid: options.keyId })
.setIssuedAt(now)
.setExpirationTime(options.expiresIn || '5m')
.sign(privateKey)
}

/**
* Verify a JWT token using jose - will throw error if invalid
*
* @see https://github.com/panva/jose
*/
interface JWTVerifyOptions {
publicJwkUrl: string
audience: string
issuer: string
}

export async function verifyJwt<T>(
token: string,
options: JWTVerifyOptions,
): Promise<T> {
const JWKS = jose.createRemoteJWKSet(new URL(options.publicJwkUrl))

const { payload } = await jose.jwtVerify(token, JWKS, {
audience: options.audience,
issuer: options.issuer,
})

return payload as T
}
2 changes: 1 addition & 1 deletion src/runtime/types/oauth-config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { H3Event, H3Error } from 'h3'

export type OAuthProvider = 'atlassian' | 'auth0' | 'authentik' | 'battledotnet' | 'cognito' | 'discord' | 'dropbox' | 'facebook' | 'github' | 'gitlab' | 'google' | 'hubspot' | 'instagram' | 'keycloak' | 'line' | 'linear' | 'linkedin' | 'microsoft' | 'paypal' | 'polar' | 'spotify' | 'seznam' | 'steam' | 'strava' | 'tiktok' | 'twitch' | 'vk' | 'workos' | 'x' | 'xsuaa' | 'yandex' | 'zitadel' | (string & {})
export type OAuthProvider = 'atlassian' | 'auth0' | 'authentik' | 'battledotnet' | 'cognito' | 'discord' | 'dropbox' | 'facebook' | 'github' | 'gitlab' | 'google' | 'hubspot' | 'instagram' | 'keycloak' | 'line' | 'linear' | 'linkedin' | 'microsoft' | 'paypal' | 'polar' | 'spotify' | 'seznam' | 'steam' | 'strava' | 'tiktok' | 'twitch' | 'vk' | 'workos' | 'x' | 'xsuaa' | 'yandex' | 'zitadel' | 'apple' | (string & {})

export type OnError = (event: H3Event, error: H3Error) => Promise<void> | void

Expand Down

0 comments on commit 4e9e5a9

Please sign in to comment.