UNPKG

@wailroth/adonis-ally-apple-v6

Version:

Ally driver for Apple Sign In

295 lines (259 loc) 8.58 kB
/* |-------------------------------------------------------------------------- | Ally Oauth driver |-------------------------------------------------------------------------- | | Make sure you through the code and comments properly and make necessary | changes as per the requirements of your implementation. | */ /** |-------------------------------------------------------------------------- * Search keyword "YourDriver" and replace it with a meaningful name |-------------------------------------------------------------------------- */ import { Oauth2Driver } from '@adonisjs/ally' import type { AllyDriverContract, ApiRequestContract, RedirectRequestContract, } from '@adonisjs/ally/types' import { HttpContext } from '@adonisjs/core/http' import * as jose from 'jose' import JWT from 'jsonwebtoken' import { AppleAccessToken, AppleDriverConfig, AppleTokenDecoded, AppleUserContract, } from './types/main.js' import { AppleScopes } from './types/main.js' /** * Custom OAuth exception class */ class OauthException extends Error { static missingAuthorizationCode(codeParamName: string) { return new OauthException( `Missing authorization code in the callback request. Make sure the "${codeParamName}" query string parameter exists` ) } static stateMisMatch() { return new OauthException('State mismatch. Possible CSRF attack attempt') } } /** * Driver implementation. It is mostly configuration driven except the API call * to get user info. */ export class AppleDriver extends Oauth2Driver<AppleAccessToken, AppleScopes> implements AllyDriverContract<AppleAccessToken, AppleScopes> { /** * The URL for the redirect request. The user will be redirected on this page * to authorize the request. */ protected authorizeUrl = 'https://appleid.apple.com/auth/authorize' /** * The URL to hit to exchange the authorization code for the access token */ protected accessTokenUrl = 'https://appleid.apple.com/auth/token' /** * JWKS Client for Apple key verification */ protected jwksClient: ReturnType<typeof jose.createRemoteJWKSet> | null = null /** * The param name for the authorization code. Read the documentation of your oauth * provider and update the param name to match the query string field name in * which the oauth provider sends the authorization_code post redirect. */ protected codeParamName = 'code' /** * The param name for the error. Read the documentation of your oauth provider and update * the param name to match the query string field name in which the oauth provider sends * the error post redirect */ protected errorParamName = 'error' /** * Cookie name for storing the CSRF token. Make sure it is always unique. So a better * approach is to prefix the oauth provider name to `oauth_state` value. For example: * For example: "facebook_oauth_state" */ protected stateCookieName = 'apple_oauth_state' /** * Parameter name to be used for sending and receiving the state from. * Read the documentation of your oauth provider and update the param * name to match the query string used by the provider for exchanging * the state. */ protected stateParamName = 'state' /** * Parameter name for sending the scopes to the oauth provider. */ protected scopeParamName = 'scope' /** * The separator indentifier for defining multiple scopes */ protected scopesSeparator = ' ' constructor( ctx: HttpContext, public config: AppleDriverConfig ) { super(ctx, config) /** * Initialize JWKS client */ this.initializeJwksClient() /** * Extremely important to call the following method to clear the * state set by the redirect request. */ this.loadState() } /** * Initialize JWKS client */ private async initializeJwksClient() { const jwks = await jose.createRemoteJWKSet(new URL('https://appleid.apple.com/auth/keys')) this.jwksClient = jwks } /** * Optionally configure the authorization redirect request. The actual request * is made by the base implementation of "Oauth2" driver and this is a * hook to pre-configure the request. */ protected configureRedirectRequest(request: RedirectRequestContract<AppleScopes>) { /** * Define user defined scopes or the default one's */ request.scopes(this.config.scopes || ['email']) request.param('client_id', this.config.appId) request.param('response_type', 'code') request.param('response_mode', 'form_post') request.param('grant_type', 'authorization_code') } /** * Update the implementation to tell if the error received during redirect * means "ACCESS DENIED". */ accessDenied(): boolean { return this.ctx.request.input('error') === 'user_denied' } /** * Get Apple Signing Key to verify token */ protected async verifyToken(token: string) { if (!this.jwksClient) { await this.initializeJwksClient() } const options = { issuer: 'https://appleid.apple.com', audience: this.config.clientId, } try { const { payload } = await jose .jwtVerify(token, this.jwksClient!, options) .catch(async (error) => { if (error?.code === 'ERR_JWKS_MULTIPLE_MATCHING_KEYS') { for await (const publicKey of error) { try { return await jose.jwtVerify(token, publicKey, options) } catch (innerError) { if (innerError?.code === 'ERR_JWS_SIGNATURE_VERIFICATION_FAILED') { continue } throw innerError } } throw new jose.errors.JWSSignatureVerificationFailed() } throw error }) return payload as AppleTokenDecoded } catch (error) { throw new Error(`Token verification failed: ${error.message}`) } } /** * Generates Client Secret * https://developer.apple.com/documentation/sign_in_with_apple/generate_and_validate_tokens * @returns clientSecret */ protected generateClientSecret(): string { const clientSecret = JWT.sign({}, this.config.clientSecret, { algorithm: 'ES256', keyid: this.config.clientId, issuer: this.config.teamId, audience: 'https://appleid.apple.com', subject: this.config.appId, expiresIn: 60, header: { alg: 'ES256', kid: this.config.clientId }, }) return clientSecret } /** * Parses user info from the Apple Token */ protected async getUserInfo(token: string): Promise<AppleUserContract> { const decodedUser = await this.verifyToken(token) const firstName = decodedUser?.user?.name?.firstName || '' const lastName = decodedUser?.user?.name?.lastName || '' return { id: decodedUser.sub, avatarUrl: null, original: null, nickName: decodedUser.sub, name: `${firstName}${lastName ? ` ${lastName}` : ''}`, email: decodedUser.email, emailVerificationState: decodedUser.email_verified === 'true' ? 'verified' : 'unverified', } } /** * Get access token */ async accessToken(callback?: (request: ApiRequestContract) => void): Promise<AppleAccessToken> { /** * We expect the user to handle errors before calling this method */ if (this.hasError()) { throw OauthException.missingAuthorizationCode(this.codeParamName) } /** * We expect the user to properly handle the state mis-match use case before * calling this method */ if (this.stateMisMatch()) { throw OauthException.stateMisMatch() } return this.getAccessToken((request) => { request.header('Content-Type', 'application/x-www-form-urlencoded') request.field('client_id', this.config.appId) request.field('client_secret', this.generateClientSecret()) request.field(this.codeParamName, this.getCode()) if (typeof callback === 'function') { callback(request) } }) } /** * Returns details for the authorized user */ async user(callback?: (request: ApiRequestContract) => void) { const token = await this.accessToken(callback) const user = await this.getUserInfo(token.id_token) return { ...user, token, } } /** * Finds the user by the access token */ async userFromToken(token: string) { const user = await this.getUserInfo(token) return { ...user, token: { token, type: 'bearer' as const }, } } }