@wailroth/adonis-ally-apple-v6
Version:
Ally driver for Apple Sign In
243 lines (242 loc) • 8.53 kB
JavaScript
/*
|--------------------------------------------------------------------------
| 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' },
};
}
}