Skip to content

Commit 4e9e5a9

Browse files
feat: add apple provider (#328)
1 parent 9d191a1 commit 4e9e5a9

File tree

11 files changed

+326
-416
lines changed

11 files changed

+326
-416
lines changed

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ Add Authentication to Nuxt applications with secured & sealed cookies sessions.
1616
## Features
1717

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

208+
- Apple
208209
- Atlassian
209210
- Auth0
210211
- Authentik

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
"defu": "^6.1.4",
4141
"h3": "^1.14.0",
4242
"hookable": "^5.5.3",
43+
"jose": "^5.9.6",
4344
"ofetch": "^1.4.1",
4445
"ohash": "^1.1.4",
4546
"openid-client": "^6.1.7",

playground/.env.example

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,3 +109,9 @@ NUXT_OAUTH_ATLASSIAN_REDIRECT_URL=
109109
NUXT_OAUTH_LINE_CLIENT_ID=
110110
NUXT_OAUTH_LINE_CLIENT_SECRET=
111111
NUXT_OAUTH_LINE_REDIRECT_URL=
112+
# Apple
113+
NUXT_OAUTH_APPLE_PRIVATE_KEY=
114+
NUXT_OAUTH_APPLE_KEY_ID=
115+
NUXT_OAUTH_APPLE_TEAM_ID=
116+
NUXT_OAUTH_APPLE_CLIENT_ID=
117+
NUXT_OAUTH_APPLE_REDIRECT_URL=

playground/app.vue

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,12 @@ const providers = computed(() =>
195195
disabled: Boolean(user.value?.atlassian),
196196
icon: 'i-simple-icons-atlassian',
197197
},
198+
{
199+
label: user.value?.apple || 'Apple',
200+
to: '/auth/apple',
201+
disabled: Boolean(user.value?.apple),
202+
icon: 'i-simple-icons-apple',
203+
},
198204
].map(p => ({
199205
...p,
200206
prefetch: false,

playground/auth.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ declare module '#auth-utils' {
3535
strava?: string
3636
hubspot?: string
3737
atlassian?: string
38+
apple?: string
3839
}
3940

4041
interface UserSession {
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
export default defineOAuthAppleEventHandler({
2+
async onSuccess(event, { user, tokens }) {
3+
const userToSet = user?.name?.firstName && user?.name?.lastName
4+
? `${user.name.firstName} ${user.name.lastName}`
5+
: user?.name?.firstName || user?.name?.lastName || tokens.email || tokens.sub
6+
7+
await setUserSession(event, {
8+
user: {
9+
apple: userToSet,
10+
},
11+
loggedInAt: Date.now(),
12+
})
13+
14+
return sendRedirect(event, '/')
15+
},
16+
})

pnpm-lock.yaml

Lines changed: 46 additions & 414 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/module.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,5 +370,13 @@ export default defineNuxtModule<ModuleOptions>({
370370
clientSecret: '',
371371
redirectURL: '',
372372
})
373+
// Apple OAuth
374+
runtimeConfig.oauth.apple = defu(runtimeConfig.oauth.apple, {
375+
teamId: '',
376+
keyId: '',
377+
privateKey: '',
378+
redirectURL: '',
379+
clientId: '',
380+
})
373381
},
374382
})

src/runtime/server/lib/oauth/apple.ts

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
import type { H3Event } from 'h3'
2+
import { eventHandler, getRequestHeader, readBody, sendRedirect } from 'h3'
3+
import { withQuery } from 'ufo'
4+
import { defu } from 'defu'
5+
import { handleMissingConfiguration, handleAccessTokenErrorResponse, getOAuthRedirectURL, requestAccessToken, signJwt, verifyJwt } from '../utils'
6+
import { useRuntimeConfig } from '#imports'
7+
import type { OAuthConfig } from '#auth-utils'
8+
9+
export interface OAuthAppleConfig {
10+
/**
11+
* Apple OAuth Client ID
12+
* @default process.env.NUXT_OAUTH_APPLE_CLIENT_ID
13+
*/
14+
clientId?: string
15+
16+
/**
17+
* Apple OAuth team ID
18+
* @default process.env.NUXT_OAUTH_APPLE_TEAM_ID
19+
*/
20+
teamId?: string
21+
22+
/**
23+
* Apple OAuth key identifier
24+
* @default process.env.NUXT_OAUTH_APPLE_KEY_ID
25+
*/
26+
keyId?: string
27+
28+
/**
29+
* Apple OAuth Private Key. Linebreaks must be replaced with \n
30+
* @example '-----BEGIN PRIVATE KEY-----\nMIGTAgEAMBMGByqGSM...\n-----END PRIVATE KEY-----'
31+
* @default process.env.NUXT_OAUTH_APPLE_PRIVATE_KEY
32+
*/
33+
privateKey?: string
34+
35+
/**
36+
* 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.
37+
* @default ''
38+
* @see https://developer.apple.com/documentation/sign_in_with_apple/clientconfigi/3230955-scope
39+
* @example 'name email'
40+
*/
41+
scope?: string | string[]
42+
43+
/**
44+
* Apple OAuth Authorization URL
45+
* @default 'https://appleid.apple.com/auth/authorize'
46+
*/
47+
authorizationURL?: string
48+
49+
/**
50+
* Extra authorization parameters to provide to the authorization URL
51+
* @see https://developer.apple.com/documentation/sign_in_with_apple/clientconfigi/3230955-scope
52+
* @example { usePop: true }
53+
*/
54+
authorizationParams?: Record<string, string | boolean>
55+
56+
/**
57+
* Apple OAuth Token URL
58+
* @default 'https://appleid.apple.com/auth/token'
59+
*/
60+
tokenURL?: string
61+
62+
/**
63+
* Redirect URL to to allow overriding for situations like prod failing to determine public hostname
64+
* @default process.env.NUXT_OAUTH_APPLE_REDIRECT_URL or current URL
65+
*/
66+
redirectURL?: string
67+
}
68+
69+
export interface OAuthAppleTokens {
70+
iss: string
71+
aud: string
72+
exp: number
73+
iat: number
74+
sub: string
75+
at_hash: string
76+
email: string
77+
email_verified: boolean
78+
is_private_email: boolean
79+
auth_time: number
80+
nonce_supported: boolean
81+
}
82+
83+
export interface OAuthAppleUser {
84+
name?: {
85+
firstName?: string
86+
lastName?: string
87+
}
88+
email?: string
89+
}
90+
91+
export function defineOAuthAppleEventHandler({
92+
config,
93+
onSuccess,
94+
onError,
95+
}: OAuthConfig<OAuthAppleConfig>) {
96+
return eventHandler(async (event: H3Event) => {
97+
config = defu(config, useRuntimeConfig(event).oauth?.apple, {
98+
authorizationURL: config?.authorizationURL || 'https://appleid.apple.com/auth/authorize',
99+
authorizationParams: {},
100+
}) as OAuthAppleConfig
101+
102+
if (!config.teamId || !config.keyId || !config.privateKey || !config.clientId) {
103+
return handleMissingConfiguration(event, 'apple', ['teamId', 'keyId', 'privateKey', 'clientId'], onError)
104+
}
105+
106+
// instead of a query, apple sends a form post back after login
107+
const isPost = getRequestHeader(event, 'content-type') === 'application/x-www-form-urlencoded'
108+
109+
let code: string | undefined
110+
let user: OAuthAppleUser | undefined
111+
112+
if (isPost) {
113+
// `user` will only be available the first time a user logs in.
114+
({ code, user } = await readBody<{ code: string, user?: OAuthAppleUser }>(event))
115+
}
116+
117+
// Send user to apple login page.
118+
if (!isPost || !code) {
119+
const redirectURL = config.redirectURL || getOAuthRedirectURL(event)
120+
121+
config.scope = Array.isArray(config.scope)
122+
? config.scope.join(' ')
123+
: (config.scope || 'name email')
124+
125+
return sendRedirect(
126+
event,
127+
withQuery(config.authorizationURL as string, {
128+
response_type: 'code',
129+
response_mode: 'form_post',
130+
client_id: config.clientId,
131+
redirect_uri: redirectURL,
132+
scope: config.scope,
133+
...config.authorizationParams,
134+
}),
135+
)
136+
}
137+
138+
// Verify the form post data we got back from apple
139+
try {
140+
const secret = await signJwt(
141+
{
142+
iss: config.teamId,
143+
aud: 'https://appleid.apple.com',
144+
sub: config.clientId,
145+
},
146+
{
147+
privateKey: config.privateKey,
148+
keyId: config.keyId,
149+
teamId: config.teamId,
150+
clientId: config.clientId,
151+
expiresIn: '5m',
152+
},
153+
)
154+
155+
const accessTokenResult = await requestAccessToken(config.tokenURL || 'https://appleid.apple.com/auth/token', {
156+
params: {
157+
client_id: config.clientId,
158+
client_secret: secret,
159+
code,
160+
grant_type: 'authorization_code',
161+
redirect_uri: config.redirectURL,
162+
},
163+
})
164+
165+
const tokens = await verifyJwt<OAuthAppleTokens>(accessTokenResult.id_token, {
166+
publicJwkUrl: 'https://appleid.apple.com/auth/keys',
167+
audience: config.clientId,
168+
issuer: 'https://appleid.apple.com',
169+
})
170+
171+
if (!tokens) {
172+
return handleAccessTokenErrorResponse(event, 'apple', tokens, onError)
173+
}
174+
175+
return onSuccess(event, { user, tokens })
176+
}
177+
catch (error) {
178+
return handleAccessTokenErrorResponse(event, 'apple', error, onError)
179+
}
180+
})
181+
}

src/runtime/server/lib/utils.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { H3Event } from 'h3'
22
import { getRequestURL } from 'h3'
33
import { FetchError } from 'ofetch'
44
import { snakeCase, upperFirst } from 'scule'
5+
import * as jose from 'jose'
56
import type { OAuthProvider, OnError } from '#auth-utils'
67
import { createError } from '#imports'
78

@@ -98,3 +99,60 @@ export function handleMissingConfiguration(event: H3Event, provider: OAuthProvid
9899
if (!onError) throw error
99100
return onError(event, error)
100101
}
102+
103+
/**
104+
* JWT signing using jose
105+
*
106+
* @see https://github.com/panva/jose
107+
*/
108+
109+
interface JWTSignOptions {
110+
privateKey: string
111+
keyId: string
112+
teamId?: string
113+
clientId?: string
114+
algorithm?: 'ES256' | 'RS256'
115+
expiresIn?: string // e.g., '5m', '1h'
116+
}
117+
118+
export async function signJwt<T extends Record<string, unknown>>(
119+
payload: T,
120+
options: JWTSignOptions,
121+
): Promise<string> {
122+
const now = Math.floor(Date.now() / 1000)
123+
const privateKey = await jose.importPKCS8(
124+
options.privateKey.replace(/\\n/g, '\n'),
125+
options.algorithm || 'ES256',
126+
)
127+
128+
return new jose.SignJWT(payload)
129+
.setProtectedHeader({ alg: options.algorithm || 'ES256', kid: options.keyId })
130+
.setIssuedAt(now)
131+
.setExpirationTime(options.expiresIn || '5m')
132+
.sign(privateKey)
133+
}
134+
135+
/**
136+
* Verify a JWT token using jose - will throw error if invalid
137+
*
138+
* @see https://github.com/panva/jose
139+
*/
140+
interface JWTVerifyOptions {
141+
publicJwkUrl: string
142+
audience: string
143+
issuer: string
144+
}
145+
146+
export async function verifyJwt<T>(
147+
token: string,
148+
options: JWTVerifyOptions,
149+
): Promise<T> {
150+
const JWKS = jose.createRemoteJWKSet(new URL(options.publicJwkUrl))
151+
152+
const { payload } = await jose.jwtVerify(token, JWKS, {
153+
audience: options.audience,
154+
issuer: options.issuer,
155+
})
156+
157+
return payload as T
158+
}

0 commit comments

Comments
 (0)