├── LICENSE ├── README.md ├── googleStrategy.test.ts ├── googleStrategy.ts ├── mod.ts └── types.ts /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 OSLabs Beta 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # dashport-googlestrategy 2 | Google OAuth 2.0 strategy for [Dashport](https://github.com/oslabs-beta/dashport) module for Deno 3 | ``` 4 | import GoogleStrategy from 'https://deno.land/x/dashport_google@v1.0.0/mod.ts' 5 | ``` 6 | -------------------------------------------------------------------------------- /googleStrategy.test.ts: -------------------------------------------------------------------------------- 1 | import GoogleStrategy from './googleStrategy.ts'; 2 | import { assertEquals, assertNotEquals } from "https://deno.land/std@0.87.0/testing/asserts.ts" 3 | 4 | const fakeOptions = { 5 | client_id:'1001553106526-ri9j20c9uipsp6q5ubqojbuc5e19dkgp.apps.googleusercontent.com', 6 | redirect_uri: 'http://localhost:3000/test', 7 | response_type: 'code', 8 | scope: 'profile email openid', 9 | client_secret: 'e44hA4VIInrJDu_isCDl3YCr', 10 | grant_type: 'authorization_code', 11 | }; 12 | const fakeOakCtx = { 13 | app: {}, 14 | cookies: {}, 15 | request: { url: {} }, 16 | respond: {}, 17 | response: {redirect: (string: string)=>string}, 18 | socket: {}, 19 | state: { 20 | _dashport: { 21 | session: '' 22 | } 23 | }, 24 | assert: () => 1, 25 | send: () => 2, 26 | sendEvents: () => 3, 27 | throw: () => 4, 28 | upgrade: () => 5, 29 | params: {} 30 | }; 31 | const fakeNext = () => 1; 32 | 33 | Deno.test({ 34 | name: "GoogleStrategy should have a router method and be initialized with correct default properties", 35 | fn(): void{ 36 | const goog = new GoogleStrategy(fakeOptions); 37 | 38 | assertNotEquals(goog.router, undefined); 39 | assertEquals(goog.name, 'google'); 40 | assertEquals(goog.options, { 41 | client_id:'1001553106526-ri9j20c9uipsp6q5ubqojbuc5e19dkgp.apps.googleusercontent.com', 42 | redirect_uri: 'http://localhost:3000/test', 43 | response_type: 'code', 44 | scope: 'profile email openid', 45 | client_secret: 'e44hA4VIInrJDu_isCDl3YCr', 46 | grant_type: 'authorization_code', 47 | }); 48 | assertEquals(goog.uriFromParams, 'client_id=1001553106526-ri9j20c9uipsp6q5ubqojbuc5e19dkgp.apps.googleusercontent.com&redirect_uri=http://localhost:3000/test&response_type=code&scope=profile email openid&'); 49 | } 50 | }); 51 | 52 | Deno.test({ 53 | name: "goog.authorize, should redirect to Google for client authorization", 54 | async fn(): Promise{ 55 | const goog = new GoogleStrategy(fakeOptions); 56 | 57 | assertEquals(await goog.authorize(fakeOakCtx, fakeNext), fakeOakCtx.response.redirect('https://accounts.google.com/o/oauth2/v2/auth?' + goog.uriFromParams)); 58 | } 59 | }); 60 | 61 | Deno.test({ 62 | name: "router method should correctly call either authorize or getAuthToken", 63 | async fn(): Promise { 64 | const goog = new GoogleStrategy(fakeOptions); 65 | 66 | fakeOakCtx.request.url = {search : undefined}; 67 | assertEquals(await goog.router(fakeOakCtx, fakeNext), await goog.authorize(fakeOakCtx, fakeNext)); 68 | fakeOakCtx.request.url = {search : "?code=testing"}; 69 | assertEquals(await goog.router(fakeOakCtx, fakeNext), await goog.getAuthToken(fakeOakCtx, fakeNext)); 70 | } 71 | }); 72 | 73 | Deno.test({ 74 | name: "getAuthToken method, should get the response from google, split string and return json data ", 75 | async fn(): Promise { 76 | const goog = new GoogleStrategy(fakeOptions); 77 | const returnVal: any = { 78 | tokenData: { 79 | access_token: undefined, 80 | expires_in: undefined, 81 | scope: undefined, 82 | token_type: undefined, 83 | id_token: undefined 84 | }, 85 | userInfo: { 86 | provider: "google", 87 | providerUserId: undefined, 88 | displayName: undefined, 89 | name: { familyName: undefined, givenName: undefined }, 90 | emails: [ undefined ] 91 | } 92 | }; 93 | 94 | assertEquals(await goog.getAuthToken(fakeOakCtx, fakeNext), returnVal) 95 | } 96 | }); 97 | 98 | Deno.test({ 99 | name: "getAuthData method should return authorization data", 100 | async fn(): Promise { 101 | const goog = new GoogleStrategy(fakeOptions); 102 | const returnVal: any = { 103 | tokenData: { 104 | access_token: undefined, 105 | expires_in: undefined, 106 | scope: undefined, 107 | token_type: undefined, 108 | id_token: undefined 109 | }, 110 | userInfo: { 111 | provider: "google", 112 | providerUserId: undefined, 113 | displayName: undefined, 114 | name: { familyName: undefined, givenName: undefined }, 115 | emails: [ undefined ] 116 | } 117 | }; 118 | 119 | assertEquals(await goog.getAuthData(returnVal.tokenData), returnVal); 120 | } 121 | }); 122 | 123 | Deno.test({ 124 | name: "parseCode method should return accurately parsed url encoding", 125 | fn() :void { 126 | const goog = new GoogleStrategy(fakeOptions); 127 | 128 | assertEquals(goog.parseCode('%24%26%2B%2C%2F%3A%3B%3D%3F%40'), '$&+,/:;=?@') 129 | } 130 | }) 131 | -------------------------------------------------------------------------------- /googleStrategy.ts: -------------------------------------------------------------------------------- 1 | import { OakContext, Options, AuthData, TokenData } from './types.ts'; 2 | 3 | /** 4 | * Creates an instance of `GoogleStrategy`. 5 | * 6 | * * Options: 7 | * 8 | * - `clientID`: string Required 9 | * - redirect_uri: string Required 10 | * - response_type: string Required 11 | * - scope: string Required 12 | * - access_type: string Recommended 13 | * - state: string Recommended 14 | * - included_granted_access: string Optional 15 | * - login_hint: string Optional 16 | * - prompt: string Optional 17 | * 18 | * Examples: 19 | * 20 | * dashport.use(new GoogleStrategy({ 21 | * authorizationURL: 'https://www.example.com/oauth2/authorize', 22 | * tokenURL: 'https://www.example.com/oauth2/token', 23 | * clientID: '123-456-789', 24 | * clientSecret: 'shhh-its-a-secret' 25 | * callbackURL: 'https://www.example.net/auth/example/callback' 26 | * }, 27 | * function(accessToken, refreshToken, profile, done) { 28 | * User.findOrCreate(..., function (err, user) { 29 | * done(err, user); 30 | * }); 31 | * } 32 | * )); 33 | * 34 | */ 35 | export default class GoogleStrategy { 36 | name: string = 'google' 37 | options: Options; 38 | uriFromParams:string; 39 | /** 40 | * @constructor 41 | * @param {Object} options 42 | * @api public 43 | */ 44 | constructor (options: Options) { 45 | if (!options.client_id || !options.redirect_uri || !options.response_type || !options.scope || !options.client_secret) { 46 | throw new Error('ERROR in GoogleStrategy constructor: Missing required arguments'); 47 | } 48 | 49 | this.options = options; 50 | 51 | // preStep1 request permission 52 | // CONSTRUCTS THE REDIRECT URI FROM THE PARAMETERS PROVIDED 53 | const paramArray: string[][] = Object.entries(options); 54 | let paramString: string = ''; 55 | 56 | for (let i = 0; i < paramArray.length; i++) { 57 | let [key, value] = paramArray[i]; 58 | 59 | if (key === 'client_secret' || key === 'grant_type') continue; 60 | 61 | paramString += (key + '='); 62 | 63 | if (i < paramArray.length - 1) paramString += (value + '&'); 64 | else paramString += value; 65 | } 66 | 67 | this.uriFromParams = paramString; 68 | } 69 | 70 | async router(ctx: OakContext, next: Function) { 71 | // GO_Step 1 Request Permission 72 | if (!ctx.request.url.search) return await this.authorize(ctx, next); 73 | // GO_Step 2-3 Exchange code for Token 74 | if (ctx.request.url.search.slice(1, 5) === 'code') return this.getAuthToken(ctx, next); 75 | } 76 | 77 | // sends the programatically constructed uri to Google's oauth 2.0 server (step 2) 78 | async authorize(ctx: OakContext, next: Function) { 79 | return await ctx.response.redirect('https://accounts.google.com/o/oauth2/v2/auth?' + this.uriFromParams); 80 | } 81 | 82 | // handle oauth 2.0 server response (step 4) 83 | async getAuthToken(ctx: OakContext, next: Function) { 84 | const OGURI: string = ctx.request.url.search; 85 | 86 | if (OGURI.includes('error')) { 87 | return new Error('ERROR in getAuthToken: Received an error from auth token code request.'); 88 | } 89 | 90 | // splits the string at the =, storing the first part in URI1[0] and the part we want in URI1[1] 91 | let URI1: string[] = OGURI.split('='); 92 | // splits the string at the ampersand(&), storing the string with the access_token in URI2[0] 93 | // and the other parameters at URI2[n] 94 | const URI2: string[] = URI1[1].split('&'); 95 | const code: string = this.parseCode(URI2[0]); 96 | 97 | const options: any = { 98 | method: 'POST', 99 | headers: { "content-type": "application/json" }, 100 | body: JSON.stringify({ 101 | client_id: this.options.client_id, 102 | client_secret: this.options.client_secret, 103 | code: code, 104 | grant_type: this.options.grant_type, 105 | redirect_uri: this.options.redirect_uri 106 | }) 107 | } 108 | try { 109 | let data: any = await fetch('https://oauth2.googleapis.com/token', options); 110 | data = await data.json(); 111 | 112 | return this.getAuthData(data); 113 | } catch(err) { 114 | return new Error(`ERROR in getAuthToken: Unable to obtain token - ${err}`); 115 | } 116 | } 117 | 118 | async getAuthData(parsed: TokenData){ 119 | const authData: AuthData = { 120 | tokenData: { 121 | access_token: parsed.access_token, 122 | expires_in: parsed.expires_in, 123 | scope: parsed.scope, 124 | token_type: parsed.token_type, 125 | id_token: parsed.id_token 126 | }, 127 | userInfo: { 128 | provider: '', 129 | providerUserId: '' 130 | } 131 | }; 132 | const options: any = { 133 | headers: { 'Authorization': 'Bearer '+ parsed.access_token } 134 | }; 135 | 136 | try { 137 | let data: any = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', options); 138 | data = await data.json(); 139 | 140 | authData.userInfo = { 141 | provider: this.name, 142 | providerUserId: data.id, 143 | displayName: data.name, 144 | name: { 145 | familyName: data.family_name, 146 | givenName: data.given_name, 147 | }, 148 | emails: [data.email] 149 | }; 150 | 151 | return authData; 152 | } catch(err) { 153 | return new Error(`ERROR in getAuthData: Unable to obtain auth data - ${err}`); 154 | } 155 | } 156 | 157 | parseCode(encodedCode: string): string { 158 | const replacements: { [name: string] : string } = { 159 | "%24": "$", 160 | "%26": "&", 161 | "%2B": "+", 162 | "%2C": ",", 163 | "%2F": "/", 164 | "%3A": ":", 165 | "%3B": ";", 166 | "%3D": "=", 167 | "%3F": "?", 168 | "%40": "@" 169 | } 170 | 171 | const toReplaceArray: string[] = Object.keys(replacements); 172 | 173 | for(let i = 0; i < toReplaceArray.length; i++) { 174 | while (encodedCode.includes(toReplaceArray[i])) { 175 | encodedCode = encodedCode.replace(toReplaceArray[i], replacements[toReplaceArray[i]]); 176 | } 177 | } 178 | 179 | return encodedCode; 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /mod.ts: -------------------------------------------------------------------------------- 1 | import GoogleStrategy from './googleStrategy.ts'; 2 | export default GoogleStrategy; 3 | -------------------------------------------------------------------------------- /types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Should contain the same properties and methods defined by Oak 3 | * https://github.com/oakserver/oak 4 | */ 5 | export interface OakContext { 6 | app: any; 7 | cookies: any; 8 | request: any; 9 | respond: any; 10 | response: any; 11 | socket: any; 12 | state: any; 13 | assert: Function; 14 | send: Function; 15 | sendEvents: Function; 16 | throw: Function; 17 | upgrade: Function; 18 | params: any; 19 | locals?: any; 20 | } 21 | 22 | /** 23 | * Different OAuths will return different user information in different 24 | * structures. Dashport strategies should break down and reconstruct the user 25 | * info into the standardized UserProfile below 26 | */ 27 | export interface UserProfile { 28 | // the provider the user is authenticated with 29 | provider: string; 30 | // the unique id a user has with that specific provider 31 | providerUserId: string; 32 | // the display name or username for this specific user 33 | displayName?: string; 34 | name?: { 35 | familyName?: string; 36 | givenName?: string; 37 | middleName?: string; 38 | }; 39 | emails?: Array; 40 | } 41 | 42 | /** 43 | * At the bare minimum, OAuth 2.0 providers will require a client ID, client 44 | * secret, and redirect URI. The remaining options depend on the OAuth 2.0 45 | * provider, such as scope 46 | */ 47 | export interface Options { 48 | client_id: string; 49 | client_secret: string; 50 | redirect_uri: string; 51 | [option: string]: string; 52 | } 53 | 54 | /** 55 | * All OAuth 2.0 providers will provide access tokens 56 | */ 57 | export interface TokenData { 58 | access_token: string; 59 | expires_in?: number; 60 | scope?: string; 61 | token_type?: string; 62 | id_token?: string; 63 | refresh_token?: string; 64 | } 65 | 66 | /** 67 | * The form the information from strategies should come back in 68 | */ 69 | export interface AuthData { 70 | tokenData: TokenData; 71 | userInfo: UserProfile; 72 | } 73 | --------------------------------------------------------------------------------