UNPKG

pkce-x

Version:

PKCE is a security extension to OAuth 2.0 for public clients on mobile devices, designed to prevent interception of the authorisation code by a malicious application that has sneaked into the same device.

187 lines (157 loc) 5.61 kB
/*! * pkce-x * Copyright(c) 2022 Nadeen Gamage * MIT Licensed */ import sha256 from 'crypto-js/sha256'; import Base64 from 'crypto-js/enc-base64'; import WordArray from 'crypto-js/lib-typedarrays'; import IConfig from "./interfaces/IConfig"; import IObject from "./interfaces/IObject"; import IAuthResponse from "./interfaces/IAuthResponse"; import ITokenResponse from "./interfaces/ITokenResponse"; export default class AuthService { private config: IConfig; private state: string = ''; private codeVerifier: string = ''; constructor(config: IConfig) { this.config = config; } public async authorize(additionalParams: IObject = {}) { if (this.getCodeFromUrl() === null) { window.location.replace(`${this.config.authorization_endpoint}?${this.getQueryString(additionalParams)}`); } } public async exchange(additionalParams: IObject = {}): Promise<ITokenResponse> { return this.parseAuthResponseUrl(window.location.href).then(async (q) => { const response = await fetch(this.config.token_endpoint, { method: 'POST', body: new URLSearchParams( Object.assign( { grant_type: 'authorization_code', code: q.code, client_id: this.config.client_id, redirect_uri: this.config.redirect_uri, code_verifier: this.getCodeVerifier(), }, additionalParams)), headers: this.getHeaders(), }); if (response.status === 200) { response.clone().json().then((data) => { this.getStore().setItem('access_token', data.access_token); this.getStore().setItem('refresh_token', (data.refresh_token !== undefined ? data.refresh_token : null)); this.getStore().setItem('expires_in', data.expires_in); this.getStore().setItem('scope', data.scope); this.getStore().setItem('token_type', data.token_type); }); } if (response.status !== 200) { window.location.replace(`${this.config.authorization_endpoint}?${this.getQueryString(additionalParams)}`); } return response.json(); }); } public getAccessToken(): string { return this.getStore().getItem('access_token') || null; } public getRefreshToken(): string { return this.getStore().getItem('refresh_token') || null; } public getExpiresIn(): number { return parseInt(this.getStore().getItem('expires_in') || '0', 10); } public getScope(): string { return this.getStore().getItem('scope') || null; } private getQueryString(additionalParams: IObject = {}): string { const codeChallenge = this.pkceChallengeFromVerifier(); const queryString = new URLSearchParams( Object.assign( { response_type: 'code', client_id: this.config.client_id, state: this.getState(additionalParams.state || null), scope: this.config.requested_scopes, redirect_uri: this.config.redirect_uri, code_challenge: codeChallenge, code_challenge_method: 'S256', }, additionalParams, ), ).toString(); return queryString; } private parseAuthResponseUrl(url: string): Promise<IAuthResponse> { const params = new URL(url).searchParams; return this.validateAuthResponse({ error: params.get('error'), query: params.get('query'), state: params.get('state'), code: params.get('code'), }); } private getHeaders(): HeadersInit { if (this.config.client_secret) { return { Accept: 'application/json', 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8', 'Authorization': `Basic ${this.getEncodedCredentials()}`, }; } else { return { Accept: 'application/json', 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8', }; } } private getCodeFromUrl(): string { const params = new URL(window.location.href).searchParams; return params.get('code') || null; } private getEncodedCredentials(): string { return btoa(`${this.config.client_id}:${this.config.client_secret}`); } private validateAuthResponse(queryParams: IAuthResponse): Promise<IAuthResponse> { return new Promise<IAuthResponse>((resolve, reject) => { if (queryParams.error) { return reject({ error: queryParams.error }); } if (queryParams.state !== this.getState()) { return reject({ error: 'Invalid State' }); } return resolve(queryParams); }); } private pkceChallengeFromVerifier(): string { const hashed = sha256(this.getCodeVerifier()); return Base64.stringify(hashed).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); } private getCodeVerifier(): string { if (this.codeVerifier === '') { this.codeVerifier = this.randomStringFromStorage('pkce_code_verifier'); } return this.codeVerifier; } private getState(state: string = null): string { const stateKey = 'pkce_state'; if (state !== null) { this.getStore().setItem(stateKey, state); } if (this.state === '') { this.state = this.randomStringFromStorage(stateKey); } return this.state; } private randomStringFromStorage(key: string): string { const fromStorage = this.getStore().getItem(key); if (fromStorage === null) { this.getStore().setItem(key, WordArray.random(64)); } return this.getStore().getItem(key) || ''; } private getStore(): Storage { return this.config?.storage || sessionStorage; } }