UNPKG

9.5 kBPlain TextView Raw
1/*
2 * Copyright 2017-2017 Amazon.com, Inc. or its affiliates. All Rights Reserved.
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance with
5 * the License. A copy of the License is located at
6 *
7 * http://aws.amazon.com/apache2.0/
8 *
9 * or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
10 * CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions
11 * and limitations under the License.
12 */
13
14import { parse } from 'url'; // Used for OAuth parsing of Cognito Hosted UI
15import { launchUri } from './urlOpener';
16import * as oAuthStorage from './oauthStorage';
17
18import {
19 OAuthOpts,
20 isCognitoHostedOpts,
21 CognitoHostedUIIdentityProvider,
22} from '../types/Auth';
23
24import { ConsoleLogger as Logger, Hub, urlSafeEncode } from '@aws-amplify/core';
25
26import sha256 from 'crypto-js/sha256';
27import Base64 from 'crypto-js/enc-base64';
28
29const AMPLIFY_SYMBOL = (typeof Symbol !== 'undefined' &&
30typeof Symbol.for === 'function'
31 ? Symbol.for('amplify_default')
32 : '@@amplify_default') as Symbol;
33
34const dispatchAuthEvent = (event: string, data: any, message: string) => {
35 Hub.dispatch('auth', { event, data, message }, 'Auth', AMPLIFY_SYMBOL);
36};
37
38const logger = new Logger('OAuth');
39
40export 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 /* encodeURIComponent is not URL safe, use urlSafeEncode instead. Cognito
83 single-encodes/decodes url on first sign in and double-encodes/decodes url
84 when user already signed in. Using encodeURIComponent, Base32, Base64 add
85 characters % or = which on further encoding becomes unsafe. '=' create issue
86 for parsing query params.
87 Refer: https://github.com/aws-amplify/amplify-js/issues/5218 */
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 /* Convert URL into an object with parameters as keys
122 { redirect_uri: 'http://localhost:3000/', response_type: 'code', ...} */
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 // hash is `null` if `#` doesn't exist on URL
198 const { id_token, access_token } = (parse(currentUrl).hash || '#')
199 .substr(1) // Remove # from returned code
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 // This is because savedState only exists if the flow was initiated by Amplify
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}