Skip to content

Commit

Permalink
feat(authentication): apple provider (#374)
Browse files Browse the repository at this point in the history
  • Loading branch information
SotiriaSte authored Oct 24, 2022
1 parent efd6dbc commit 47ec41f
Show file tree
Hide file tree
Showing 14 changed files with 331 additions and 16 deletions.
2 changes: 2 additions & 0 deletions modules/authentication/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,10 @@
"crypto": "^1.0.1",
"escape-string-regexp": "^4.0.0",
"jsonwebtoken": "^8.5.1",
"jwks-rsa": "^2.1.4",
"lodash": "^4.17.21",
"moment": "^2.29.4",
"querystring": "^0.2.1",
"uuid": "^8.3.2"
},
"devDependencies": {
Expand Down
42 changes: 42 additions & 0 deletions modules/authentication/src/config/apple.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
export default {
apple: {
enabled: {
format: 'Boolean',
default: false,
},
clientId: {
doc: 'The client id that is provided by apple developer console for a specific app',
format: 'String',
default: '',
},
redirect_uri: {
doc:
'Defines the uri that the user will be redirected to, ' +
'on successful apple login, when using the redirect method',
format: 'String',
default: '',
},
privateKey: {
doc: 'The private key that is provided by apple developer console for a specific app',
format: 'String',
default: '',
},
teamId: {
doc: 'The team id that is provided by apple developer console for a specific app',
format: 'String',
default: '',
},
keyId: {
doc: 'The private key id that is provided by apple developer console for a specific app',
format: 'String',
default: '',
},
accountLinking: {
doc:
'When enabled, if a new apple user matches with an existing email on the database, ' +
'they will be enriched with apple details',
format: 'Boolean',
default: true,
},
},
};
2 changes: 2 additions & 0 deletions modules/authentication/src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import gitlabConfig from './gitlab.config';
import redditConfig from './reddit.config';
import bitbucketConfig from './bitbucket.config';
import linkedInConfig from './linkedIn.config';
import appleConfig from './apple.config';
import twitterConfig from './twitter.config';

const AppConfigSchema = {
Expand All @@ -29,6 +30,7 @@ const AppConfigSchema = {
...localConfig,
...magicLinkConfig,
...gitlabConfig,
...appleConfig,
...twitterConfig,
...redditConfig,
...bitbucketConfig,
Expand Down
28 changes: 16 additions & 12 deletions modules/authentication/src/handlers/oauth2/OAuth2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ export abstract class OAuth2<T, S extends OAuth2Settings>
const scopes = call.request.params?.scopes ?? this.defaultScopes;
const conduitUrl = (await this.grpcSdk.config.get('router')).hostUrl;
let codeChallenge;
if (this.settings.hasOwnProperty('codeChallengeMethod')) {
if (!isNil(this.settings.codeChallengeMethod)) {
codeChallenge = createHash('sha256')
.update(this.settings.codeVerifier!)
.digest('base64')
Expand All @@ -84,16 +84,20 @@ export abstract class OAuth2<T, S extends OAuth2Settings>
};
const baseUrl = this.settings.authorizeUrl;

const stateToken = await Token.getInstance().create({
type: TokenType.STATE_TOKEN,
token: uuid(),
data: {
clientId: call.request.context.clientId,
scope: options.scope,
codeChallenge: codeChallenge,
expiresAt: new Date(Date.now() + 10 * 60 * 1000),
},
});
const stateToken = await Token.getInstance()
.create({
type: TokenType.STATE_TOKEN,
token: uuid(),
data: {
clientId: call.request.context.clientId,
scope: options.scope,
codeChallenge: codeChallenge,
expiresAt: new Date(Date.now() + 10 * 60 * 1000),
},
})
.catch(err => {
throw new GrpcError(status.INTERNAL, err);
});
options['state'] = stateToken.token;

const keys = Object.keys(options) as [keyof RedirectOptions];
Expand Down Expand Up @@ -127,7 +131,7 @@ export abstract class OAuth2<T, S extends OAuth2Settings>
if (this.settings.hasOwnProperty('grantType')) {
myParams['grant_type'] = this.settings.grantType;
}
if (this.settings.hasOwnProperty('codeChallengeMethod')) {
if (!isNil(this.settings.codeChallengeMethod)) {
myParams['code_verifier'] = this.settings.codeVerifier;
}

Expand Down
9 changes: 9 additions & 0 deletions modules/authentication/src/handlers/oauth2/apple/apple.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"accessTokenMethod": "POST",
"providerName": "apple",
"authorizeUrl": "https://appleid.apple.com/auth/authorize?",
"tokenUrl": "https://appleid.apple.com/auth/token",
"grantType": "authorization_code",
"responseType": "code id_token",
"responseMode": "form_post"
}
210 changes: 210 additions & 0 deletions modules/authentication/src/handlers/oauth2/apple/apple.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
import { OAuth2 } from '../OAuth2';
import ConduitGrpcSdk, {
ConduitRouteActions,
ConduitRouteReturnDefinition,
ConduitString,
ConfigController,
GrpcError,
ParsedRouterRequest,
RoutingManager,
} from '@conduitplatform/grpc-sdk';
import * as appleParameters from '../apple/apple.json';
import { ConnectionParams } from '../interfaces/ConnectionParams';
import { Payload } from '../interfaces/Payload';
import axios from 'axios';
import { AppleUser } from './apple.user';
import * as jwt from 'jsonwebtoken';
import { TokenProvider } from '../../tokenProvider';
import { Token } from '../../../models';
import { isNil } from 'lodash';
import { status } from '@grpc/grpc-js';
import moment from 'moment';
import jwksRsa from 'jwks-rsa';
import { Jwt, JwtHeader, JwtPayload } from 'jsonwebtoken';
import qs from 'querystring';
import { AppleOAuth2Settings } from '../interfaces/AppleOAuth2Settings';
import { AppleProviderConfig } from '../interfaces/AppleProviderConfig';

export class AppleHandlers extends OAuth2<AppleUser, AppleOAuth2Settings> {
constructor(grpcSdk: ConduitGrpcSdk, config: { apple: AppleProviderConfig }) {
super(grpcSdk, 'apple', new AppleOAuth2Settings(config.apple, appleParameters));
this.defaultScopes = ['name', 'email'];
}

async validate(): Promise<boolean> {
const authConfig = ConfigController.getInstance().config;
if (!authConfig['apple'].enabled) {
ConduitGrpcSdk.Logger.log(`Apple authentication not available`);
return (this.initialized = false);
}
if (
!authConfig['apple'] ||
!authConfig['apple'].clientId ||
!authConfig['apple'].privateKey ||
!authConfig['apple'].teamId
) {
ConduitGrpcSdk.Logger.log(`Apple authentication not available`);
return (this.initialized = false);
}
ConduitGrpcSdk.Logger.log(`Apple authentication is available`);
return (this.initialized = true);
}

// @ts-ignore
// we don't implement this method for apple provider
async connectWithProvider(details: ConnectionParams): Promise<Payload<AppleUser>> {}

constructScopes(scopes: string[]): string {
return scopes.join(' ');
}

async authorize(call: ParsedRouterRequest) {
const params = call.request.params;
const stateToken: Token | null = await Token.getInstance().findOne({
token: params.state,
});
if (isNil(stateToken))
throw new GrpcError(status.INVALID_ARGUMENT, 'Invalid parameters');
if (moment().isAfter(moment(stateToken.data.expiresAt))) {
await Token.getInstance().deleteOne(stateToken);
throw new GrpcError(status.INVALID_ARGUMENT, 'Token expired');
}
const decoded_id_token = jwt.decode(params.id_token, { complete: true });

const publicKeys = await axios.get('https://appleid.apple.com/auth/keys');
const publicKey = publicKeys.data.keys.find(
(key: any) => key.kid === decoded_id_token!.header.kid,
);
const applePublicKey = await this.generateApplePublicKey(publicKey.kid);
this.verifyIdentityToken(applePublicKey, params.id_token);

const apple_private_key = this.settings.privateKey;

const jwtHeader = {
alg: 'ES256',
kid: this.settings.keyId,
};

const jwtPayload = {
iss: this.settings.teamId,
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + 86400,
aud: 'https://appleid.apple.com',
sub: this.settings.clientId,
};

const apple_client_secret = jwt.sign(jwtPayload, apple_private_key as any, {
algorithm: 'ES256',
header: jwtHeader,
});

const clientId = this.settings.clientId;
const conduitUrl = (await this.grpcSdk.config.get('router')).hostUrl;
const postData = qs.stringify({
client_id: clientId,
client_secret: apple_client_secret,
code: params.code,
grant_type: this.settings.grantType,
redirect_uri: `${conduitUrl}/hook/authentication/${this.settings.providerName}`,
});
const req = {
method: this.settings.accessTokenMethod,
url: this.settings.tokenUrl,
data: postData,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
};
const appleResponseToken = await axios(req).catch(err => {
throw new GrpcError(status.INTERNAL, err.message);
});

const data = appleResponseToken.data;
const id_token = data.id_token;
const decoded = jwt.decode(id_token, { complete: true }) as Jwt;
const payload = decoded.payload as JwtPayload;
if (decoded_id_token!.payload.sub !== payload.sub) {
throw new GrpcError(status.INVALID_ARGUMENT, 'Invalid token');
}
const userParams = {
id: payload.sub!,
email: payload.email,
data: { ...payload.email_verified },
};
const user = await this.createOrUpdateUser(userParams);
await Token.getInstance().deleteOne(stateToken);
const config = ConfigController.getInstance().config;
ConduitGrpcSdk.Metrics?.increment('logged_in_users_total');

return TokenProvider.getInstance()!.provideUserTokens(
{
user,
clientId,
config,
},
this.settings.finalRedirect,
);
}

declareRoutes(routingManager: RoutingManager) {
routingManager.route(
{
path: `/init/apple`,
description: `Begins Apple authentication.`,
action: ConduitRouteActions.GET,
},
new ConduitRouteReturnDefinition(`AppleInitResponse`, 'String'),
this.redirect.bind(this),
);

routingManager.route(
{
path: `/hook/apple`,
action: ConduitRouteActions.POST,
description: `Login/register with Apple using redirect.`,
bodyParams: {
code: ConduitString.Required,
id_token: ConduitString.Required,
state: ConduitString.Required,
},
},
new ConduitRouteReturnDefinition(`AppleResponse`, {
accessToken: ConduitString.Optional,
refreshToken: ConduitString.Optional,
}),
this.authorize.bind(this),
);
}

private async generateApplePublicKey(apple_public_key_id: string) {
const client = jwksRsa({
jwksUri: 'https://appleid.apple.com/auth/keys',
cache: true,
});
const key = await client.getSigningKey(apple_public_key_id);
return key.getPublicKey();
}

private verifyIdentityToken(applePublicKey: string, id_token: string) {
const decoded = jwt.decode(id_token, { complete: true }) as Jwt;
const payload = decoded.payload as JwtPayload;
const header = decoded.header as JwtHeader;
const verified = jwt.verify(id_token, applePublicKey, {
algorithms: [header.alg as jwt.Algorithm],
});
if (!verified) {
throw new GrpcError(status.INVALID_ARGUMENT, 'Invalid token');
}

if (payload.iss !== 'https://appleid.apple.com') {
throw new GrpcError(status.INVALID_ARGUMENT, 'Invalid iss');
}
if (payload.aud !== this.settings.clientId) {
throw new GrpcError(status.INVALID_ARGUMENT, 'Invalid aud');
}

if (payload.exp! < moment().unix()) {
throw new GrpcError(status.INVALID_ARGUMENT, 'Token expired');
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export interface AppleUser {
sub: string;
email: string;
name: { firstName: string; lastName: string };
isPrivateEmail?: boolean;
email_verified?: boolean;
real_user_status?: number;
}
1 change: 1 addition & 0 deletions modules/authentication/src/handlers/oauth2/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export * from './microsoft/microsoft';
export * from './slack/slack';
export * from './twitch/twitch';
export * from './gitlab/gitlab';
export * from './apple/apple';
export * from './twitter/twitter';
export * from './reddit/reddit';
export * from './bitbucket/bitbucket';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { OAuth2Settings } from './OAuth2Settings';
import { AppleProviderConfig } from './AppleProviderConfig';

export class AppleOAuth2Settings extends OAuth2Settings {
privateKey: string;
teamId: string;
keyId: string;

constructor(
config: AppleProviderConfig,
parameters: {
accessTokenMethod: string;
grantType: string;
authorizeUrl: string;
tokenUrl: string;
responseType: string;
responseMode: string;
},
) {
super(config, parameters);
this.privateKey = config.privateKey;
this.teamId = config.teamId;
this.keyId = config.keyId;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { ProviderConfig } from './ProviderConfig';

export interface AppleProviderConfig extends ProviderConfig {
privateKey: string;
teamId: string;
keyId: string;
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
export interface AuthParams {
client_id: string;
client_secret: string;
client_secret?: string;
code: string;
redirect_uri: string;
grant_type?: string;
Expand Down
Loading

0 comments on commit 47ec41f

Please sign in to comment.