UNPKG

@wailroth/adonis-ally-apple-v6

Version:

Ally driver for Apple Sign In

243 lines (242 loc) 8.53 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 * as jose from 'jose'; import JWT from 'jsonwebtoken'; /** * Custom OAuth exception class */ class OauthException extends Error { static missingAuthorizationCode(codeParamName) { 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 { config; /** * The URL for the redirect request. The user will be redirected on this page * to authorize the request. */ authorizeUrl = 'https://appleid.apple.com/auth/authorize'; /** * The URL to hit to exchange the authorization code for the access token */ accessTokenUrl = 'https://appleid.apple.com/auth/token'; /** * JWKS Client for Apple key verification */ jwksClient = 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. */ 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 */ 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" */ 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. */ stateParamName = 'state'; /** * Parameter name for sending the scopes to the oauth provider. */ scopeParamName = 'scope'; /** * The separator indentifier for defining multiple scopes */ scopesSeparator = ' '; constructor(ctx, config) { super(ctx, config); this.config = 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 */ 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. */ configureRedirectRequest(request) { /** * 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() { return this.ctx.request.input('error') === 'user_denied'; } /** * Get Apple Signing Key to verify token */ async verifyToken(token) { 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; } 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 */ generateClientSecret() { 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 */ async getUserInfo(token) { 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) { /** * 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) { 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) { const user = await this.getUserInfo(token); return { ...user, token: { token, type: 'bearer' }, }; } }