1 |
|
2 |
|
3 |
|
4 |
|
5 |
|
6 |
|
7 |
|
8 |
|
9 |
|
10 |
|
11 |
|
12 |
|
13 |
|
14 | import { parse } from 'url';
|
15 | import { launchUri } from './urlOpener';
|
16 | import * as oAuthStorage from './oauthStorage';
|
17 |
|
18 | import {
|
19 | OAuthOpts,
|
20 | isCognitoHostedOpts,
|
21 | CognitoHostedUIIdentityProvider,
|
22 | } from '../types/Auth';
|
23 |
|
24 | import { ConsoleLogger as Logger, Hub, urlSafeEncode } from '@aws-amplify/core';
|
25 |
|
26 | import sha256 from 'crypto-js/sha256';
|
27 | import Base64 from 'crypto-js/enc-base64';
|
28 |
|
29 | const AMPLIFY_SYMBOL = (typeof Symbol !== 'undefined' &&
|
30 | typeof Symbol.for === 'function'
|
31 | ? Symbol.for('amplify_default')
|
32 | : '@@amplify_default') as Symbol;
|
33 |
|
34 | const dispatchAuthEvent = (event: string, data: any, message: string) => {
|
35 | Hub.dispatch('auth', { event, data, message }, 'Auth', AMPLIFY_SYMBOL);
|
36 | };
|
37 |
|
38 | const logger = new Logger('OAuth');
|
39 |
|
40 | export default class OAuth {
|
41 | private _urlOpener;
|
42 | private _config;
|
43 | private _cognitoClientId;
|
44 | private _scopes;
|
45 |
|
46 | constructor({
|
47 | config,
|
48 | cognitoClientId,
|
49 | scopes = [],
|
50 | }: {
|
51 | scopes: string[];
|
52 | config: OAuthOpts;
|
53 | cognitoClientId: string;
|
54 | }) {
|
55 | this._urlOpener = config.urlOpener || launchUri;
|
56 | this._config = config;
|
57 | this._cognitoClientId = cognitoClientId;
|
58 |
|
59 | if (!this.isValidScopes(scopes))
|
60 | throw Error('scopes must be a String Array');
|
61 | this._scopes = scopes;
|
62 | }
|
63 |
|
64 | private isValidScopes(scopes: string[]) {
|
65 | return (
|
66 | Array.isArray(scopes) && scopes.every(scope => typeof scope === 'string')
|
67 | );
|
68 | }
|
69 |
|
70 | public oauthSignIn(
|
71 | responseType = 'code',
|
72 | domain: string,
|
73 | redirectSignIn: string,
|
74 | clientId: string,
|
75 | provider:
|
76 | | CognitoHostedUIIdentityProvider
|
77 | | string = CognitoHostedUIIdentityProvider.Cognito,
|
78 | customState?: string
|
79 | ) {
|
80 | const generatedState = this._generateState(32);
|
81 |
|
82 | |
83 |
|
84 |
|
85 |
|
86 |
|
87 |
|
88 | const state = customState
|
89 | ? `${generatedState}-${urlSafeEncode(customState)}`
|
90 | : generatedState;
|
91 |
|
92 | oAuthStorage.setState(state);
|
93 |
|
94 | const pkce_key = this._generateRandom(128);
|
95 | oAuthStorage.setPKCE(pkce_key);
|
96 |
|
97 | const code_challenge = this._generateChallenge(pkce_key);
|
98 | const code_challenge_method = 'S256';
|
99 |
|
100 | const scopesString = this._scopes.join(' ');
|
101 |
|
102 | const queryString = Object.entries({
|
103 | redirect_uri: redirectSignIn,
|
104 | response_type: responseType,
|
105 | client_id: clientId,
|
106 | identity_provider: provider,
|
107 | scope: scopesString,
|
108 | state,
|
109 | ...(responseType === 'code' ? { code_challenge } : {}),
|
110 | ...(responseType === 'code' ? { code_challenge_method } : {}),
|
111 | })
|
112 | .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
|
113 | .join('&');
|
114 |
|
115 | const URL = `https://${domain}/oauth2/authorize?${queryString}`;
|
116 | logger.debug(`Redirecting to ${URL}`);
|
117 | this._urlOpener(URL, redirectSignIn);
|
118 | }
|
119 |
|
120 | private async _handleCodeFlow(currentUrl: string) {
|
121 | |
122 |
|
123 | const { code } = (parse(currentUrl).query || '')
|
124 | .split('&')
|
125 | .map(pairings => pairings.split('='))
|
126 | .reduce((accum, [k, v]) => ({ ...accum, [k]: v }), { code: undefined });
|
127 |
|
128 | const currentUrlPathname = parse(currentUrl).pathname || '/';
|
129 | const redirectSignInPathname =
|
130 | parse(this._config.redirectSignIn).pathname || '/';
|
131 |
|
132 | if (!code || currentUrlPathname !== redirectSignInPathname) {
|
133 | return;
|
134 | }
|
135 |
|
136 | const oAuthTokenEndpoint =
|
137 | 'https://' + this._config.domain + '/oauth2/token';
|
138 |
|
139 | dispatchAuthEvent(
|
140 | 'codeFlow',
|
141 | {},
|
142 | `Retrieving tokens from ${oAuthTokenEndpoint}`
|
143 | );
|
144 |
|
145 | const client_id = isCognitoHostedOpts(this._config)
|
146 | ? this._cognitoClientId
|
147 | : this._config.clientID;
|
148 |
|
149 | const redirect_uri = isCognitoHostedOpts(this._config)
|
150 | ? this._config.redirectSignIn
|
151 | : this._config.redirectUri;
|
152 |
|
153 | const code_verifier = oAuthStorage.getPKCE();
|
154 |
|
155 | const oAuthTokenBody = {
|
156 | grant_type: 'authorization_code',
|
157 | code,
|
158 | client_id,
|
159 | redirect_uri,
|
160 | ...(code_verifier ? { code_verifier } : {}),
|
161 | };
|
162 |
|
163 | logger.debug(
|
164 | `Calling token endpoint: ${oAuthTokenEndpoint} with`,
|
165 | oAuthTokenBody
|
166 | );
|
167 |
|
168 | const body = Object.entries(oAuthTokenBody)
|
169 | .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
|
170 | .join('&');
|
171 |
|
172 | const {
|
173 | access_token,
|
174 | refresh_token,
|
175 | id_token,
|
176 | error,
|
177 | } = await ((await fetch(oAuthTokenEndpoint, {
|
178 | method: 'POST',
|
179 | headers: {
|
180 | 'Content-Type': 'application/x-www-form-urlencoded',
|
181 | },
|
182 | body,
|
183 | })) as any).json();
|
184 |
|
185 | if (error) {
|
186 | throw new Error(error);
|
187 | }
|
188 |
|
189 | return {
|
190 | accessToken: access_token,
|
191 | refreshToken: refresh_token,
|
192 | idToken: id_token,
|
193 | };
|
194 | }
|
195 |
|
196 | private async _handleImplicitFlow(currentUrl: string) {
|
197 |
|
198 | const { id_token, access_token } = (parse(currentUrl).hash || '#')
|
199 | .substr(1)
|
200 | .split('&')
|
201 | .map(pairings => pairings.split('='))
|
202 | .reduce((accum, [k, v]) => ({ ...accum, [k]: v }), {
|
203 | id_token: undefined,
|
204 | access_token: undefined,
|
205 | });
|
206 |
|
207 | dispatchAuthEvent('implicitFlow', {}, `Got tokens from ${currentUrl}`);
|
208 | logger.debug(`Retrieving implicit tokens from ${currentUrl} with`);
|
209 |
|
210 | return {
|
211 | accessToken: access_token,
|
212 | idToken: id_token,
|
213 | refreshToken: null,
|
214 | };
|
215 | }
|
216 |
|
217 | public async handleAuthResponse(currentUrl?: string) {
|
218 | try {
|
219 | const urlParams = currentUrl
|
220 | ? ({
|
221 | ...(parse(currentUrl).hash || '#')
|
222 | .substr(1)
|
223 | .split('&')
|
224 | .map(entry => entry.split('='))
|
225 | .reduce((acc, [k, v]) => ((acc[k] = v), acc), {}),
|
226 | ...(parse(currentUrl).query || '')
|
227 | .split('&')
|
228 | .map(entry => entry.split('='))
|
229 | .reduce((acc, [k, v]) => ((acc[k] = v), acc), {}),
|
230 | } as any)
|
231 | : {};
|
232 | const { error, error_description } = urlParams;
|
233 |
|
234 | if (error) {
|
235 | throw new Error(error_description);
|
236 | }
|
237 |
|
238 | const state: string = this._validateState(urlParams);
|
239 |
|
240 | logger.debug(
|
241 | `Starting ${this._config.responseType} flow with ${currentUrl}`
|
242 | );
|
243 | if (this._config.responseType === 'code') {
|
244 | return { ...(await this._handleCodeFlow(currentUrl)), state };
|
245 | } else {
|
246 | return { ...(await this._handleImplicitFlow(currentUrl)), state };
|
247 | }
|
248 | } catch (e) {
|
249 | logger.error(`Error handling auth response.`, e);
|
250 | throw e;
|
251 | }
|
252 | }
|
253 |
|
254 | private _validateState(urlParams: any): string {
|
255 | if (!urlParams) {
|
256 | return;
|
257 | }
|
258 |
|
259 | const savedState = oAuthStorage.getState();
|
260 | const { state: returnedState } = urlParams;
|
261 |
|
262 |
|
263 | if (savedState && savedState !== returnedState) {
|
264 | throw new Error('Invalid state in OAuth flow');
|
265 | }
|
266 | return returnedState;
|
267 | }
|
268 |
|
269 | public async signOut() {
|
270 | let oAuthLogoutEndpoint = 'https://' + this._config.domain + '/logout?';
|
271 |
|
272 | const client_id = isCognitoHostedOpts(this._config)
|
273 | ? this._cognitoClientId
|
274 | : this._config.oauth.clientID;
|
275 |
|
276 | const signout_uri = isCognitoHostedOpts(this._config)
|
277 | ? this._config.redirectSignOut
|
278 | : this._config.returnTo;
|
279 |
|
280 | oAuthLogoutEndpoint += Object.entries({
|
281 | client_id,
|
282 | logout_uri: encodeURIComponent(signout_uri),
|
283 | })
|
284 | .map(([k, v]) => `${k}=${v}`)
|
285 | .join('&');
|
286 |
|
287 | dispatchAuthEvent(
|
288 | 'oAuthSignOut',
|
289 | { oAuth: 'signOut' },
|
290 | `Signing out from ${oAuthLogoutEndpoint}`
|
291 | );
|
292 | logger.debug(`Signing out from ${oAuthLogoutEndpoint}`);
|
293 |
|
294 | return this._urlOpener(oAuthLogoutEndpoint, signout_uri);
|
295 | }
|
296 |
|
297 | private _generateState(length: number) {
|
298 | let result = '';
|
299 | let i = length;
|
300 | const chars =
|
301 | '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
302 | for (; i > 0; --i)
|
303 | result += chars[Math.round(Math.random() * (chars.length - 1))];
|
304 | return result;
|
305 | }
|
306 |
|
307 | private _generateChallenge(code: string) {
|
308 | return this._base64URL(sha256(code));
|
309 | }
|
310 |
|
311 | private _base64URL(string) {
|
312 | return string
|
313 | .toString(Base64)
|
314 | .replace(/=/g, '')
|
315 | .replace(/\+/g, '-')
|
316 | .replace(/\//g, '_');
|
317 | }
|
318 |
|
319 | private _generateRandom(size: number) {
|
320 | const CHARSET =
|
321 | 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
|
322 | const buffer = new Uint8Array(size);
|
323 | if (typeof window !== 'undefined' && !!window.crypto) {
|
324 | window.crypto.getRandomValues(buffer);
|
325 | } else {
|
326 | for (let i = 0; i < size; i += 1) {
|
327 | buffer[i] = (Math.random() * CHARSET.length) | 0;
|
328 | }
|
329 | }
|
330 | return this._bufferToString(buffer);
|
331 | }
|
332 |
|
333 | private _bufferToString(buffer: Uint8Array) {
|
334 | const CHARSET =
|
335 | 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
336 | const state = [];
|
337 | for (let i = 0; i < buffer.byteLength; i += 1) {
|
338 | const index = buffer[i] % CHARSET.length;
|
339 | state.push(CHARSET[index]);
|
340 | }
|
341 | return state.join('');
|
342 | }
|
343 | }
|