-
Notifications
You must be signed in to change notification settings - Fork 25
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(authentication): apple provider (#374)
- Loading branch information
1 parent
efd6dbc
commit 47ec41f
Showing
14 changed files
with
331 additions
and
16 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
}, | ||
}, | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
210
modules/authentication/src/handlers/oauth2/apple/apple.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'); | ||
} | ||
} | ||
} |
8 changes: 8 additions & 0 deletions
8
modules/authentication/src/handlers/oauth2/apple/apple.user.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
25 changes: 25 additions & 0 deletions
25
modules/authentication/src/handlers/oauth2/interfaces/AppleOAuth2Settings.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
} |
7 changes: 7 additions & 0 deletions
7
modules/authentication/src/handlers/oauth2/interfaces/AppleProviderConfig.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
2 changes: 1 addition & 1 deletion
2
modules/authentication/src/handlers/oauth2/interfaces/AuthParams.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.