├── .github └── workflows │ ├── build.yml │ ├── code-coverage.yml │ ├── compressed-size.yml │ └── run-tests.yml ├── .gitignore ├── .prettierrc ├── babel.config.js ├── examples ├── angular │ ├── .angular-cli.json │ ├── .editorconfig │ ├── .gitignore │ ├── README.md │ ├── e2e │ │ ├── app.po.ts │ │ └── tsconfig.e2e.json │ ├── karma.conf.js │ ├── package-lock.json │ ├── package.json │ ├── protractor.conf.js │ ├── src │ │ ├── app │ │ │ ├── app-routing.module.ts │ │ │ ├── app.component.css │ │ │ ├── app.component.html │ │ │ ├── app.component.ts │ │ │ ├── app.module.ts │ │ │ ├── auth-modal │ │ │ │ ├── auth-modal.component.css │ │ │ │ ├── auth-modal.component.html │ │ │ │ └── auth-modal.component.ts │ │ │ ├── auth.config.ts │ │ │ ├── home │ │ │ │ ├── home.component.css │ │ │ │ ├── home.component.html │ │ │ │ └── home.component.ts │ │ │ └── redirect │ │ │ │ ├── redirect.component.css │ │ │ │ ├── redirect.component.html │ │ │ │ └── redirect.component.ts │ │ ├── assets │ │ │ └── .gitkeep │ │ ├── environments │ │ │ ├── environment.prod.ts │ │ │ └── environment.ts │ │ ├── favicon.ico │ │ ├── index.html │ │ ├── main.ts │ │ ├── polyfills.ts │ │ ├── styles.css │ │ ├── tsconfig.app.json │ │ ├── tsconfig.spec.json │ │ └── typings.d.ts │ ├── tsconfig.json │ └── tslint.json ├── react-example │ ├── .env │ ├── .gitignore │ ├── README.md │ ├── package-lock.json │ ├── package.json │ ├── public │ │ ├── favicon.ico │ │ ├── index.html │ │ └── manifest.json │ └── src │ │ ├── App.js │ │ ├── AuthenticatedRoute.js │ │ ├── Redirect.js │ │ ├── UnauthenticatedRoute.js │ │ ├── auth.js │ │ ├── index.css │ │ └── index.js └── vanilla │ ├── index.html │ ├── index.js │ ├── package-lock.json │ ├── package.json │ ├── redirect.html │ ├── redirect.js │ └── styles.css ├── license.md ├── package-lock.json ├── package.json ├── readme.md ├── rollup.config.js ├── src ├── index.test.ts └── index.ts └── tsconfig.json /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | on: 3 | - pull_request 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v2 10 | - uses: actions/setup-node@v1 11 | with: 12 | node-version: '12.x' 13 | - run: npm i 14 | - run: npm run build 15 | -------------------------------------------------------------------------------- /.github/workflows/code-coverage.yml: -------------------------------------------------------------------------------- 1 | name: Code Coverage 2 | on: 3 | - push 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v2 10 | - uses: actions/setup-node@v1 11 | with: 12 | node-version: '12.x' 13 | - name: npm install 14 | run: npm i 15 | - name: npm test 16 | run: npm t -- --coverage 17 | - name: Upload coverage to Codecov 18 | uses: codecov/codecov-action@v1 19 | -------------------------------------------------------------------------------- /.github/workflows/compressed-size.yml: -------------------------------------------------------------------------------- 1 | name: Compressed Size 2 | 3 | on: [pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - uses: actions/checkout@v2-beta 11 | with: 12 | fetch-depth: 1 13 | - uses: preactjs/compressed-size-action@v1 14 | with: 15 | repo-token: '${{ secrets.GITHUB_TOKEN }}' 16 | -------------------------------------------------------------------------------- /.github/workflows/run-tests.yml: -------------------------------------------------------------------------------- 1 | name: Run Tests 2 | on: 3 | - pull_request 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v2 10 | - uses: actions/setup-node@v1 11 | with: 12 | node-version: '12.x' 13 | - name: npm install 14 | run: npm i 15 | - name: npm test 16 | run: npm t 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | coverage 4 | .DS_Store 5 | docs 6 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "singleQuote": true 4 | } -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | ['@babel/preset-env', { targets: { node: 'current' } }], 4 | '@babel/preset-typescript', 5 | ], 6 | }; 7 | -------------------------------------------------------------------------------- /examples/angular/.angular-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "project": { 4 | "name": "angular" 5 | }, 6 | "apps": [ 7 | { 8 | "root": "src", 9 | "outDir": "dist", 10 | "assets": [ 11 | "assets", 12 | "favicon.ico" 13 | ], 14 | "index": "index.html", 15 | "main": "main.ts", 16 | "polyfills": "polyfills.ts", 17 | "test": "test.ts", 18 | "tsconfig": "tsconfig.app.json", 19 | "testTsconfig": "tsconfig.spec.json", 20 | "prefix": "app", 21 | "styles": [ 22 | "styles.css" 23 | ], 24 | "scripts": [], 25 | "environmentSource": "environments/environment.ts", 26 | "environments": { 27 | "dev": "environments/environment.ts", 28 | "prod": "environments/environment.prod.ts" 29 | } 30 | } 31 | ], 32 | "e2e": { 33 | "protractor": { 34 | "config": "./protractor.conf.js" 35 | } 36 | }, 37 | "lint": [ 38 | { 39 | "project": "src/tsconfig.app.json", 40 | "exclude": "**/node_modules/**" 41 | }, 42 | { 43 | "project": "src/tsconfig.spec.json", 44 | "exclude": "**/node_modules/**" 45 | }, 46 | { 47 | "project": "e2e/tsconfig.e2e.json", 48 | "exclude": "**/node_modules/**" 49 | } 50 | ], 51 | "test": { 52 | "karma": { 53 | "config": "./karma.conf.js" 54 | } 55 | }, 56 | "defaults": { 57 | "styleExt": "css", 58 | "component": {} 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /examples/angular/.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | max_line_length = off 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /examples/angular/.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | /dist 5 | /dist-server 6 | /tmp 7 | /out-tsc 8 | 9 | # dependencies 10 | /node_modules 11 | 12 | # IDEs and editors 13 | /.idea 14 | .project 15 | .classpath 16 | .c9/ 17 | *.launch 18 | .settings/ 19 | *.sublime-workspace 20 | 21 | # IDE - VSCode 22 | .vscode/* 23 | !.vscode/settings.json 24 | !.vscode/tasks.json 25 | !.vscode/launch.json 26 | !.vscode/extensions.json 27 | 28 | # misc 29 | /.sass-cache 30 | /connect.lock 31 | /coverage 32 | /libpeerconnection.log 33 | npm-debug.log 34 | yarn-error.log 35 | testem.log 36 | /typings 37 | 38 | # e2e 39 | /e2e/*.js 40 | /e2e/*.map 41 | 42 | # System Files 43 | .DS_Store 44 | Thumbs.db 45 | -------------------------------------------------------------------------------- /examples/angular/README.md: -------------------------------------------------------------------------------- 1 | # Angular 2 | 3 | This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 1.7.2. 4 | 5 | ## Development server 6 | 7 | Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The app will automatically reload if you change any of the source files. 8 | 9 | ## Code scaffolding 10 | 11 | Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module`. 12 | 13 | ## Build 14 | 15 | Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory. Use the `-prod` flag for a production build. 16 | 17 | ## Running unit tests 18 | 19 | Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io). 20 | 21 | ## Running end-to-end tests 22 | 23 | Run `ng e2e` to execute the end-to-end tests via [Protractor](http://www.protractortest.org/). 24 | 25 | ## Further help 26 | 27 | To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI README](https://github.com/angular/angular-cli/blob/master/README.md). 28 | -------------------------------------------------------------------------------- /examples/angular/e2e/app.po.ts: -------------------------------------------------------------------------------- 1 | import { browser, by, element } from 'protractor'; 2 | 3 | export class AppPage { 4 | navigateTo() { 5 | return browser.get('/'); 6 | } 7 | 8 | getParagraphText() { 9 | return element(by.css('app-root h1')).getText(); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /examples/angular/e2e/tsconfig.e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../out-tsc/e2e", 5 | "baseUrl": "./", 6 | "module": "commonjs", 7 | "target": "es5", 8 | "types": [ 9 | "jasmine", 10 | "jasminewd2", 11 | "node" 12 | ] 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /examples/angular/karma.conf.js: -------------------------------------------------------------------------------- 1 | // Karma configuration file, see link for more information 2 | // https://karma-runner.github.io/1.0/config/configuration-file.html 3 | 4 | module.exports = function (config) { 5 | config.set({ 6 | basePath: '', 7 | frameworks: ['jasmine', '@angular/cli'], 8 | plugins: [ 9 | require('karma-jasmine'), 10 | require('karma-chrome-launcher'), 11 | require('karma-jasmine-html-reporter'), 12 | require('karma-coverage-istanbul-reporter'), 13 | require('@angular/cli/plugins/karma') 14 | ], 15 | client:{ 16 | clearContext: false // leave Jasmine Spec Runner output visible in browser 17 | }, 18 | coverageIstanbulReporter: { 19 | reports: [ 'html', 'lcovonly' ], 20 | fixWebpackSourcePaths: true 21 | }, 22 | angularCli: { 23 | environment: 'dev' 24 | }, 25 | reporters: ['progress', 'kjhtml'], 26 | port: 9876, 27 | colors: true, 28 | logLevel: config.LOG_INFO, 29 | autoWatch: true, 30 | browsers: ['Chrome'], 31 | singleRun: false 32 | }); 33 | }; 34 | -------------------------------------------------------------------------------- /examples/angular/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "angular", 3 | "version": "0.0.0", 4 | "license": "MIT", 5 | "scripts": { 6 | "ng": "ng", 7 | "start": "ng serve", 8 | "build": "ng build --prod", 9 | "test": "ng test", 10 | "lint": "ng lint", 11 | "e2e": "ng e2e" 12 | }, 13 | "private": true, 14 | "dependencies": { 15 | "@angular/animations": "^5.2.0", 16 | "@angular/common": "^5.2.0", 17 | "@angular/compiler": "^5.2.0", 18 | "@angular/core": "^5.2.0", 19 | "@angular/forms": "^5.2.0", 20 | "@angular/http": "^5.2.0", 21 | "@angular/platform-browser": "^5.2.0", 22 | "@angular/platform-browser-dynamic": "^5.2.0", 23 | "@angular/router": "^5.2.0", 24 | "core-js": "^2.4.1", 25 | "font-awesome": "^4.7.0", 26 | "normalize.css": "^8.0.0", 27 | "oauth2-popup-flow": "file:../..", 28 | "rxjs": "^5.5.6", 29 | "zone.js": "^0.8.19" 30 | }, 31 | "devDependencies": { 32 | "@angular/cli": "~1.7.2", 33 | "@angular/compiler-cli": "^5.2.0", 34 | "@angular/language-service": "^5.2.0", 35 | "@types/jasmine": "~2.8.3", 36 | "@types/jasminewd2": "~2.0.2", 37 | "@types/node": "~6.0.60", 38 | "codelyzer": "^4.0.1", 39 | "jasmine-core": "~2.8.0", 40 | "jasmine-spec-reporter": "~4.2.1", 41 | "karma": "~2.0.0", 42 | "karma-chrome-launcher": "~2.2.0", 43 | "karma-coverage-istanbul-reporter": "^1.2.1", 44 | "karma-jasmine": "~1.1.0", 45 | "karma-jasmine-html-reporter": "^0.2.2", 46 | "protractor": "~5.1.2", 47 | "ts-node": "~4.1.0", 48 | "tslint": "~5.9.1", 49 | "typescript": "~2.5.3" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /examples/angular/protractor.conf.js: -------------------------------------------------------------------------------- 1 | // Protractor configuration file, see link for more information 2 | // https://github.com/angular/protractor/blob/master/lib/config.ts 3 | 4 | const { SpecReporter } = require('jasmine-spec-reporter'); 5 | 6 | exports.config = { 7 | allScriptsTimeout: 11000, 8 | specs: [ 9 | './e2e/**/*.e2e-spec.ts' 10 | ], 11 | capabilities: { 12 | 'browserName': 'chrome' 13 | }, 14 | directConnect: true, 15 | baseUrl: 'http://localhost:4200/', 16 | framework: 'jasmine', 17 | jasmineNodeOpts: { 18 | showColors: true, 19 | defaultTimeoutInterval: 30000, 20 | print: function() {} 21 | }, 22 | onPrepare() { 23 | require('ts-node').register({ 24 | project: 'e2e/tsconfig.e2e.json' 25 | }); 26 | jasmine.getEnv().addReporter(new SpecReporter({ spec: { displayStacktrace: true } })); 27 | } 28 | }; 29 | -------------------------------------------------------------------------------- /examples/angular/src/app/app-routing.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { RouterModule, Routes } from '@angular/router'; 3 | import { RedirectComponent } from './redirect/redirect.component'; 4 | import { HomeComponent } from './home/home.component'; 5 | 6 | const routes: Routes = [ 7 | { path: 'redirect', component: RedirectComponent }, 8 | { path: '', component: HomeComponent, pathMatch: 'full' }, 9 | ]; 10 | 11 | @NgModule({ 12 | imports: [RouterModule.forRoot(routes)], 13 | exports: [RouterModule], 14 | }) 15 | export class AppRoutingModule { } 16 | -------------------------------------------------------------------------------- /examples/angular/src/app/app.component.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ricokahler/oauth2-popup-flow/f15a7603ea96bc00a28e0d7feabde8b6e149cf89/examples/angular/src/app/app.component.css -------------------------------------------------------------------------------- /examples/angular/src/app/app.component.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /examples/angular/src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-root', 5 | templateUrl: './app.component.html', 6 | styleUrls: ['./app.component.css'] 7 | }) 8 | export class AppComponent { 9 | title = 'app'; 10 | 11 | constructor( 12 | ) { } 13 | } 14 | -------------------------------------------------------------------------------- /examples/angular/src/app/app.module.ts: -------------------------------------------------------------------------------- 1 | import { BrowserModule } from '@angular/platform-browser'; 2 | import { NgModule } from '@angular/core'; 3 | 4 | import { OAuth2PopupFlow } from 'oauth2-popup-flow'; 5 | import { auth } from './auth.config'; 6 | 7 | import { AppComponent } from './app.component'; 8 | import { AuthModalComponent } from './auth-modal/auth-modal.component'; 9 | import { RedirectComponent } from './redirect/redirect.component'; 10 | import { AppRoutingModule } from './/app-routing.module'; 11 | import { HomeComponent } from './home/home.component'; 12 | 13 | @NgModule({ 14 | declarations: [ 15 | AppComponent, 16 | AuthModalComponent, 17 | RedirectComponent, 18 | HomeComponent 19 | ], 20 | imports: [ 21 | BrowserModule, 22 | AppRoutingModule 23 | ], 24 | providers: [{ provide: OAuth2PopupFlow, useValue: auth }], 25 | bootstrap: [AppComponent] 26 | }) 27 | export class AppModule { } 28 | -------------------------------------------------------------------------------- /examples/angular/src/app/auth-modal/auth-modal.component.css: -------------------------------------------------------------------------------- 1 | :host { 2 | width: 100vw; 3 | height: 100vh; 4 | top: 0; 5 | left: 0; 6 | width: 100vw; 7 | height: 100vh; 8 | z-index: 100; 9 | } 10 | 11 | .modal-container { 12 | display: flex; 13 | align-items: center; 14 | justify-content: center; 15 | position: absolute; 16 | top: 0; 17 | left: 0; 18 | width: 100vw; 19 | height: 100vh; 20 | z-index: 102; 21 | } 22 | 23 | .modal { 24 | background-color: white; 25 | padding: 1rem; 26 | margin: 1rem; 27 | display: flex; 28 | flex-direction: column; 29 | align-items: center; 30 | max-width: 100vw; 31 | width: 30rem; 32 | } 33 | 34 | .spinner { margin-bottom: 1rem; } 35 | .header { 36 | margin-bottom: 1rem; 37 | font-size: 2.618rem; 38 | font-weight: 500; 39 | } 40 | .message { 41 | font-size: 1.618rem; 42 | margin-bottom: 1rem; 43 | } 44 | .sub-message { 45 | } 46 | 47 | .retry-login { 48 | color: blue; 49 | cursor: pointer; 50 | } 51 | .retry-login:hover { 52 | text-decoration: underline; 53 | } 54 | 55 | .background { 56 | position: absolute; 57 | top: 0; 58 | left: 0; 59 | width: 100vw; 60 | height: 100vh; 61 | background-color: black; 62 | opacity: 0.1; 63 | z-index: 101; 64 | } 65 | 66 | .login-button { 67 | border: 0.1rem solid lightgray; 68 | padding: 0.618rem; 69 | margin-bottom: 1rem; 70 | color: black; 71 | outline: none; 72 | } 73 | 74 | .login-button:hover { 75 | border: 0.1rem solid gray; 76 | } 77 | .login-botton:active { 78 | border: 0.1rem solid black; 79 | } -------------------------------------------------------------------------------- /examples/angular/src/app/auth-modal/auth-modal.component.html: -------------------------------------------------------------------------------- 1 | 2 | 14 |
15 |
-------------------------------------------------------------------------------- /examples/angular/src/app/auth-modal/auth-modal.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { OAuth2PopupFlow } from 'oauth2-popup-flow'; 3 | import { TokenPayload } from '../auth.config'; 4 | 5 | @Component({ 6 | selector: 'auth-modal', 7 | templateUrl: './auth-modal.component.html', 8 | styleUrls: ['./auth-modal.component.css'] 9 | }) 10 | export class AuthModalComponent implements OnInit { 11 | 12 | constructor( 13 | public auth: OAuth2PopupFlow, 14 | ) { } 15 | 16 | async ngOnInit() { 17 | } 18 | 19 | async onRetryClick() { 20 | await this.auth.tryLoginPopup(); 21 | } 22 | 23 | } 24 | -------------------------------------------------------------------------------- /examples/angular/src/app/auth.config.ts: -------------------------------------------------------------------------------- 1 | import { OAuth2PopupFlow } from 'oauth2-popup-flow'; 2 | 3 | export interface TokenPayload { 4 | given_name: string, 5 | family_name: string, 6 | nickname: string, 7 | name: string, 8 | picture: string, 9 | gender: string, 10 | locale: string, 11 | updated_at: string, 12 | iss: string, 13 | sub: string, 14 | aud: string, 15 | iat: number, 16 | exp: number, 17 | nonce: string, 18 | } 19 | 20 | function time(milliseconds: number) { 21 | return new Promise<'TIMER'>(resolve => setTimeout(() => resolve('TIMER'), milliseconds)); 22 | } 23 | 24 | export const auth = new OAuth2PopupFlow({ 25 | // you would get this values from `environment.ts` in real use. 26 | authorizationUri: 'https://formandfocus.auth0.com/authorize', 27 | clientId: 'v90UOqUtmib6bTNIm3zHuYboekqoAXwN', 28 | redirectUri: 'http://localhost:4200/redirect', 29 | scope: 'openid profile', 30 | responseType: 'id_token', 31 | accessTokenResponseKey: 'id_token', 32 | additionalAuthorizationParameters: { 33 | nonce: Math.random().toString(), 34 | }, 35 | beforePopup: () => time(1000), 36 | }); 37 | -------------------------------------------------------------------------------- /examples/angular/src/app/home/home.component.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ricokahler/oauth2-popup-flow/f15a7603ea96bc00a28e0d7feabde8b6e149cf89/examples/angular/src/app/home/home.component.css -------------------------------------------------------------------------------- /examples/angular/src/app/home/home.component.html: -------------------------------------------------------------------------------- 1 | Hello, {{name}}! 2 | -------------------------------------------------------------------------------- /examples/angular/src/app/home/home.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { OAuth2PopupFlow } from 'oauth2-popup-flow'; 3 | import { TokenPayload } from '../auth.config'; 4 | 5 | @Component({ 6 | selector: 'app-home', 7 | templateUrl: './home.component.html', 8 | styleUrls: ['./home.component.css'] 9 | }) 10 | export class HomeComponent implements OnInit { 11 | 12 | name = ''; 13 | 14 | constructor( 15 | public auth: OAuth2PopupFlow, 16 | ) { } 17 | 18 | async ngOnInit() { 19 | const payload = await this.auth.tokenPayload(); 20 | this.name = payload.name; 21 | } 22 | 23 | onLogoutClick() { 24 | this.auth.logout(); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /examples/angular/src/app/redirect/redirect.component.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ricokahler/oauth2-popup-flow/f15a7603ea96bc00a28e0d7feabde8b6e149cf89/examples/angular/src/app/redirect/redirect.component.css -------------------------------------------------------------------------------- /examples/angular/src/app/redirect/redirect.component.html: -------------------------------------------------------------------------------- 1 |

2 | one moment please 3 |

4 | -------------------------------------------------------------------------------- /examples/angular/src/app/redirect/redirect.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { OAuth2PopupFlow } from 'oauth2-popup-flow'; 3 | import { TokenPayload } from '../auth.config'; 4 | 5 | @Component({ 6 | selector: 'app-redirect', 7 | templateUrl: './redirect.component.html', 8 | styleUrls: ['./redirect.component.css'] 9 | }) 10 | export class RedirectComponent implements OnInit { 11 | 12 | constructor( 13 | public auth: OAuth2PopupFlow, 14 | ) { } 15 | 16 | ngOnInit() { 17 | this.auth.handleRedirect(); 18 | window.close(); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /examples/angular/src/assets/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ricokahler/oauth2-popup-flow/f15a7603ea96bc00a28e0d7feabde8b6e149cf89/examples/angular/src/assets/.gitkeep -------------------------------------------------------------------------------- /examples/angular/src/environments/environment.prod.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: true 3 | }; 4 | -------------------------------------------------------------------------------- /examples/angular/src/environments/environment.ts: -------------------------------------------------------------------------------- 1 | // The file contents for the current environment will overwrite these during build. 2 | // The build system defaults to the dev environment which uses `environment.ts`, but if you do 3 | // `ng build --env=prod` then `environment.prod.ts` will be used instead. 4 | // The list of which env maps to which file can be found in `.angular-cli.json`. 5 | 6 | export const environment = { 7 | production: false 8 | }; 9 | -------------------------------------------------------------------------------- /examples/angular/src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ricokahler/oauth2-popup-flow/f15a7603ea96bc00a28e0d7feabde8b6e149cf89/examples/angular/src/favicon.ico -------------------------------------------------------------------------------- /examples/angular/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Angular 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /examples/angular/src/main.ts: -------------------------------------------------------------------------------- 1 | import { enableProdMode } from '@angular/core'; 2 | import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; 3 | 4 | import { AppModule } from './app/app.module'; 5 | import { environment } from './environments/environment'; 6 | 7 | if (environment.production) { 8 | enableProdMode(); 9 | } 10 | 11 | platformBrowserDynamic().bootstrapModule(AppModule) 12 | .catch(err => console.log(err)); 13 | -------------------------------------------------------------------------------- /examples/angular/src/polyfills.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This file includes polyfills needed by Angular and is loaded before the app. 3 | * You can add your own extra polyfills to this file. 4 | * 5 | * This file is divided into 2 sections: 6 | * 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers. 7 | * 2. Application imports. Files imported after ZoneJS that should be loaded before your main 8 | * file. 9 | * 10 | * The current setup is for so-called "evergreen" browsers; the last versions of browsers that 11 | * automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera), 12 | * Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile. 13 | * 14 | * Learn more in https://angular.io/docs/ts/latest/guide/browser-support.html 15 | */ 16 | 17 | /*************************************************************************************************** 18 | * BROWSER POLYFILLS 19 | */ 20 | 21 | /** IE9, IE10 and IE11 requires all of the following polyfills. **/ 22 | // import 'core-js/es6/symbol'; 23 | // import 'core-js/es6/object'; 24 | // import 'core-js/es6/function'; 25 | // import 'core-js/es6/parse-int'; 26 | // import 'core-js/es6/parse-float'; 27 | // import 'core-js/es6/number'; 28 | // import 'core-js/es6/math'; 29 | // import 'core-js/es6/string'; 30 | // import 'core-js/es6/date'; 31 | // import 'core-js/es6/array'; 32 | // import 'core-js/es6/regexp'; 33 | // import 'core-js/es6/map'; 34 | // import 'core-js/es6/weak-map'; 35 | // import 'core-js/es6/set'; 36 | 37 | /** IE10 and IE11 requires the following for NgClass support on SVG elements */ 38 | // import 'classlist.js'; // Run `npm install --save classlist.js`. 39 | 40 | /** IE10 and IE11 requires the following for the Reflect API. */ 41 | // import 'core-js/es6/reflect'; 42 | 43 | 44 | /** Evergreen browsers require these. **/ 45 | // Used for reflect-metadata in JIT. If you use AOT (and only Angular decorators), you can remove. 46 | import 'core-js/es7/reflect'; 47 | 48 | 49 | /** 50 | * Required to support Web Animations `@angular/platform-browser/animations`. 51 | * Needed for: All but Chrome, Firefox and Opera. http://caniuse.com/#feat=web-animation 52 | **/ 53 | // import 'web-animations-js'; // Run `npm install --save web-animations-js`. 54 | 55 | /** 56 | * By default, zone.js will patch all possible macroTask and DomEvents 57 | * user can disable parts of macroTask/DomEvents patch by setting following flags 58 | */ 59 | 60 | // (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame 61 | // (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick 62 | // (window as any).__zone_symbol__BLACK_LISTED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames 63 | 64 | /* 65 | * in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js 66 | * with the following flag, it will bypass `zone.js` patch for IE/Edge 67 | */ 68 | // (window as any).__Zone_enable_cross_context_check = true; 69 | 70 | /*************************************************************************************************** 71 | * Zone JS is required by default for Angular itself. 72 | */ 73 | import 'zone.js/dist/zone'; // Included with Angular CLI. 74 | 75 | 76 | 77 | /*************************************************************************************************** 78 | * APPLICATION IMPORTS 79 | */ 80 | -------------------------------------------------------------------------------- /examples/angular/src/styles.css: -------------------------------------------------------------------------------- 1 | /* You can add global styles to this file, and also import other style files */ 2 | @import '~font-awesome/css/font-awesome.min.css'; 3 | @import '~normalize.css'; 4 | 5 | * { font-family: sans-serif; } -------------------------------------------------------------------------------- /examples/angular/src/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../out-tsc/app", 5 | "baseUrl": "./", 6 | "module": "es2015", 7 | "types": [] 8 | }, 9 | "exclude": [ 10 | "test.ts", 11 | "**/*.spec.ts" 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /examples/angular/src/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../out-tsc/spec", 5 | "baseUrl": "./", 6 | "module": "commonjs", 7 | "types": [ 8 | "jasmine", 9 | "node" 10 | ] 11 | }, 12 | "files": [ 13 | "test.ts" 14 | ], 15 | "include": [ 16 | "**/*.spec.ts", 17 | "**/*.d.ts" 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /examples/angular/src/typings.d.ts: -------------------------------------------------------------------------------- 1 | /* SystemJS module definition */ 2 | declare var module: NodeModule; 3 | interface NodeModule { 4 | id: string; 5 | } 6 | -------------------------------------------------------------------------------- /examples/angular/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "outDir": "./dist/out-tsc", 5 | "sourceMap": true, 6 | "declaration": false, 7 | "moduleResolution": "node", 8 | "emitDecoratorMetadata": true, 9 | "experimentalDecorators": true, 10 | "target": "es5", 11 | "typeRoots": [ 12 | "node_modules/@types" 13 | ], 14 | "lib": [ 15 | "es2017", 16 | "dom" 17 | ] 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /examples/angular/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "rulesDirectory": [ 3 | "node_modules/codelyzer" 4 | ], 5 | "rules": { 6 | "arrow-return-shorthand": true, 7 | "callable-types": true, 8 | "class-name": true, 9 | "comment-format": [ 10 | true, 11 | "check-space" 12 | ], 13 | "curly": true, 14 | "deprecation": { 15 | "severity": "warn" 16 | }, 17 | "eofline": true, 18 | "forin": true, 19 | "import-blacklist": [ 20 | true, 21 | "rxjs", 22 | "rxjs/Rx" 23 | ], 24 | "import-spacing": true, 25 | "indent": [ 26 | true, 27 | "spaces" 28 | ], 29 | "interface-over-type-literal": true, 30 | "label-position": true, 31 | "max-line-length": [ 32 | true, 33 | 140 34 | ], 35 | "member-access": false, 36 | "member-ordering": [ 37 | true, 38 | { 39 | "order": [ 40 | "static-field", 41 | "instance-field", 42 | "static-method", 43 | "instance-method" 44 | ] 45 | } 46 | ], 47 | "no-arg": true, 48 | "no-bitwise": true, 49 | "no-console": [ 50 | true, 51 | "debug", 52 | "info", 53 | "time", 54 | "timeEnd", 55 | "trace" 56 | ], 57 | "no-construct": true, 58 | "no-debugger": true, 59 | "no-duplicate-super": true, 60 | "no-empty": false, 61 | "no-empty-interface": true, 62 | "no-eval": true, 63 | "no-inferrable-types": [ 64 | true, 65 | "ignore-params" 66 | ], 67 | "no-misused-new": true, 68 | "no-non-null-assertion": true, 69 | "no-shadowed-variable": true, 70 | "no-string-literal": false, 71 | "no-string-throw": true, 72 | "no-switch-case-fall-through": true, 73 | "no-trailing-whitespace": true, 74 | "no-unnecessary-initializer": true, 75 | "no-unused-expression": true, 76 | "no-use-before-declare": true, 77 | "no-var-keyword": true, 78 | "object-literal-sort-keys": false, 79 | "one-line": [ 80 | true, 81 | "check-open-brace", 82 | "check-catch", 83 | "check-else", 84 | "check-whitespace" 85 | ], 86 | "prefer-const": true, 87 | "quotemark": [ 88 | true, 89 | "single" 90 | ], 91 | "radix": true, 92 | "semicolon": [ 93 | true, 94 | "always" 95 | ], 96 | "triple-equals": [ 97 | true, 98 | "allow-null-check" 99 | ], 100 | "typedef-whitespace": [ 101 | true, 102 | { 103 | "call-signature": "nospace", 104 | "index-signature": "nospace", 105 | "parameter": "nospace", 106 | "property-declaration": "nospace", 107 | "variable-declaration": "nospace" 108 | } 109 | ], 110 | "unified-signatures": true, 111 | "variable-name": false, 112 | "whitespace": [ 113 | true, 114 | "check-branch", 115 | "check-decl", 116 | "check-operator", 117 | "check-separator", 118 | "check-type" 119 | ], 120 | "directive-selector": [ 121 | true, 122 | "attribute", 123 | "app", 124 | "camelCase" 125 | ], 126 | "component-selector": [ 127 | true, 128 | "element", 129 | "app", 130 | "kebab-case" 131 | ], 132 | "no-output-on-prefix": true, 133 | "use-input-property-decorator": true, 134 | "use-output-property-decorator": true, 135 | "use-host-property-decorator": true, 136 | "no-input-rename": true, 137 | "no-output-rename": true, 138 | "use-life-cycle-interface": true, 139 | "use-pipe-transform-interface": true, 140 | "component-class-suffix": true, 141 | "directive-class-suffix": true 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /examples/react-example/.env: -------------------------------------------------------------------------------- 1 | SKIP_PREFLIGHT_CHECK=true 2 | PORT=8080 -------------------------------------------------------------------------------- /examples/react-example/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /examples/react-example/README.md: -------------------------------------------------------------------------------- 1 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 2 | 3 | # OAuth2 Popup Flow–React and React-Router Example 4 | 5 | There are more than one way to approach integrating OAuth2 Popup Flow with React and React-Router. The following is one example and assumes you know how react-router works. 6 | 7 | ## Running the demo 8 | 9 | ```bash 10 | git clone https://github.com/ricokahler/oauth2-popup-flow.git 11 | cd oauth2-popup-flow/examples/react-example 12 | npm install 13 | npm start 14 | ``` 15 | 16 | ### Installation instructions 17 | 18 | ``` 19 | npm install --save oauth2-popup-flow 20 | ``` 21 | 22 | ## How it works 23 | 24 | ### Review of the implicit grant/flow with a popup: 25 | 26 | 1. User clicks "Login" button. This will open the authorization page/login page in a new tab. 27 | 2. User enters their login info in the authorization page. 28 | 3. Authorization server redirect the user's browser (still in that new tab) to the configured redirect/callback URL including an encoded token in the `#` of the redirect URL (e.g. `http://localhost:8080/redirect#access_token=SOME.TOKEN.HERE`) 29 | 4. Based on the `/redirect` route, front-end will handle the redirect and try to parse the token out of the URL. If this is successful, the new tab will close. 30 | 5. Original tab will receive tokens and normal app flow continues. 31 | 32 | ### Example app overview 33 | 34 | 1. `App` is the first component rendered and includes a common `AppBar` and `react-router`'s `` component. 35 | 2. The `App` supports 3 routes: 36 | 1. `/authenticated` which will only render when the user is logged in. 37 | 2. `/unauthenticated` which will render when the user is not logged in. 38 | 3. `/redirect` which will render to handle the redirect/callback the authorization server redirect the user back to. 39 | 3. The `App` component add event listeners to the `auth` singleton for both `login` and `logout` events. The handlers on those events call `history.push` or `history.replace` to either push or redirect the user to the correct routes (`/authenticated` and `/unauthenticated` respectively). 40 | 4. The `Redirect` component/route calls `auth.handleRedirect()` to handle the redirect and cause the calling window to close the tab. 41 | 42 | The key files to look at are: 43 | 44 | 1. [auth.js](./src/auth.js)–creates the auth singleton 45 | 2. [App.js](./src/App.js)–manages top-level routing 46 | 3. [AuthenticatedRoute.js](./src/AuthenticatedRoute.js)–the authenticated route 47 | 4. [UnauthenticatedRoute.js](./src/UnauthenticatedRoute.js)–the unauthenticated route/login call to action 48 | 5. [Redirect.js](./src/Redirect.js)–handles the redirect from the authorization server. 49 | 50 | 51 | Enjoy! 52 | -------------------------------------------------------------------------------- /examples/react-example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-example", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@material-ui/core": "^3.8.3", 7 | "normalize.css": "^8.0.1", 8 | "oauth2-popup-flow": "^0.1.1", 9 | "react": "^16.7.0", 10 | "react-dom": "^16.7.0", 11 | "react-router": "^4.3.1", 12 | "react-router-dom": "^4.3.1", 13 | "react-scripts": "2.1.3", 14 | "recompose": "^0.30.0" 15 | }, 16 | "scripts": { 17 | "start": "react-scripts start", 18 | "build": "react-scripts build", 19 | "test": "react-scripts test", 20 | "eject": "react-scripts eject" 21 | }, 22 | "eslintConfig": { 23 | "extends": "react-app" 24 | }, 25 | "browserslist": [ 26 | ">0.2%", 27 | "not dead", 28 | "not ie <= 11", 29 | "not op_mini all" 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /examples/react-example/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ricokahler/oauth2-popup-flow/f15a7603ea96bc00a28e0d7feabde8b6e149cf89/examples/react-example/public/favicon.ico -------------------------------------------------------------------------------- /examples/react-example/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 10 | 11 | 15 | 16 | 25 | React App 26 | 27 | 28 | 29 |
30 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /examples/react-example/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /examples/react-example/src/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import compose from 'recompose/compose'; 4 | import auth from './auth'; 5 | import withStyles from '@material-ui/core/styles/withStyles'; 6 | import AppBar from '@material-ui/core/AppBar'; 7 | import Toolbar from '@material-ui/core/Toolbar'; 8 | import Typography from '@material-ui/core/Typography'; 9 | import Button from '@material-ui/core/Button'; 10 | import { Switch, Route, Redirect, withRouter } from 'react-router'; 11 | 12 | import AuthenticatedRoute from './AuthenticatedRoute'; 13 | import UnauthenticatedRoute from './UnauthenticatedRoute'; 14 | import RedirectCallback from './Redirect'; 15 | 16 | const styles = theme => { 17 | return { 18 | root: { 19 | height: '100%', 20 | width: '100%', 21 | display: 'flex', 22 | flexDirection: 'column', 23 | }, 24 | appBar: { 25 | flex: '0 0 auto', 26 | }, 27 | grow: { 28 | flexGrow: 1, 29 | }, 30 | body: { 31 | flex: '1 1 auto', 32 | display: 'flex', 33 | flexDirection: 'column', 34 | }, 35 | }; 36 | }; 37 | 38 | class App extends React.Component { 39 | static propTypes = { 40 | classes: PropTypes.object.isRequired, 41 | history: PropTypes.object.isRequired, 42 | }; 43 | 44 | componentDidMount() { 45 | auth.addEventListener('login', this.handleLogin); 46 | auth.addEventListener('logout', this.handleLogout); 47 | } 48 | 49 | componentWillUnmount() { 50 | auth.removeEventListener('login', this.handleLogin); 51 | auth.removeEventListener('logout', this.handleLogout); 52 | } 53 | 54 | handleLogin = () => { 55 | const { history } = this.props; 56 | history.push('/authenticated'); 57 | }; 58 | 59 | handleLogout = () => { 60 | const { history } = this.props; 61 | history.replace('/unauthenticated'); 62 | }; 63 | 64 | handleClick = async () => { 65 | if (auth.loggedIn()) { 66 | auth.logout(); 67 | } else { 68 | await auth.tryLoginPopup(); 69 | } 70 | }; 71 | 72 | render() { 73 | const { classes } = this.props; 74 | 75 | return ( 76 |
77 | 78 | 79 | 80 | Example App 81 | 82 | 83 | 84 | 85 |
86 | 87 | 88 | 89 | 90 | 91 | 92 |
93 |
94 | ); 95 | } 96 | } 97 | 98 | export default compose( 99 | withRouter, 100 | withStyles(styles), 101 | )(App); 102 | -------------------------------------------------------------------------------- /examples/react-example/src/AuthenticatedRoute.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import auth from './auth'; 4 | import withStyles from '@material-ui/core/styles/withStyles'; 5 | import Typography from '@material-ui/core/Typography'; 6 | import { Redirect } from 'react-router'; 7 | 8 | const styles = () => { 9 | return { 10 | root: { 11 | display: 'flex', 12 | flexDirection: 'column', 13 | alignItems: 'center', 14 | justifyContent: 'center', 15 | flex: '1 1 auto', 16 | }, 17 | }; 18 | }; 19 | 20 | class AuthenticatedRoute extends React.Component { 21 | state = { 22 | name: '', 23 | }; 24 | 25 | static propTypes = { 26 | classes: PropTypes.object.isRequired, 27 | }; 28 | 29 | async componentDidMount() { 30 | const payload = await auth.tokenPayload(); 31 | this.setState({ name: payload.given_name || payload.name }); 32 | } 33 | 34 | render() { 35 | const { classes } = this.props; 36 | const { name } = this.state; 37 | 38 | return ( 39 |
40 | {!auth.loggedIn() && } 41 | 42 | Welcome, {name}! 43 | 44 |
45 | ); 46 | } 47 | } 48 | 49 | export default withStyles(styles)(AuthenticatedRoute); 50 | -------------------------------------------------------------------------------- /examples/react-example/src/Redirect.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import withStyles from '@material-ui/core/styles/withStyles'; 4 | import Typography from '@material-ui/core/Typography'; 5 | import auth from './auth'; 6 | 7 | const styles = theme => { 8 | return { 9 | root: {}, 10 | }; 11 | }; 12 | 13 | class Redirect extends React.Component { 14 | state = { 15 | status: 'LOGGING_IN', 16 | }; 17 | 18 | static propTypes = { 19 | classes: PropTypes.object.isRequired, 20 | }; 21 | 22 | componentDidMount() { 23 | const result = auth.handleRedirect(); 24 | if (result !== 'SUCCESS') { 25 | this.setState({ status: 'FAILED' }); 26 | return; 27 | } 28 | 29 | this.setState({ status: 'SUCCESS' }); 30 | } 31 | 32 | render() { 33 | const { classes } = this.props; 34 | const { status } = this.state; 35 | return ( 36 |
37 | 38 | {status === 'LOGGING_IN' 39 | ? 'Logging you in…' 40 | : status === 'FAILED' 41 | ? 'Login failed. Please close this window and try again.' 42 | : 'You may close this window.'} 43 | 44 |
45 | ); 46 | } 47 | } 48 | 49 | export default withStyles(styles)(Redirect); 50 | -------------------------------------------------------------------------------- /examples/react-example/src/UnauthenticatedRoute.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import withStyles from '@material-ui/core/styles/withStyles'; 3 | import Button from '@material-ui/core/Button'; 4 | import auth from './auth'; 5 | 6 | const styles = () => { 7 | return { 8 | root: { 9 | display: 'flex', 10 | flexDirection: 'column', 11 | alignItems: 'center', 12 | justifyContent: 'center', 13 | flex: '1 1 auto', 14 | }, 15 | }; 16 | }; 17 | 18 | class UnauthenticatedRoute extends React.Component { 19 | handleLogin = async () => { 20 | await auth.tryLoginPopup(); 21 | }; 22 | 23 | render() { 24 | const { classes } = this.props; 25 | return ( 26 |
27 | 30 |
to continue.
31 |
32 | ); 33 | } 34 | } 35 | 36 | export default withStyles(styles)(UnauthenticatedRoute); 37 | -------------------------------------------------------------------------------- /examples/react-example/src/auth.js: -------------------------------------------------------------------------------- 1 | import { OAuth2PopupFlow } from 'oauth2-popup-flow'; 2 | 3 | export default new OAuth2PopupFlow({ 4 | authorizationUri: 'https://formandfocus.auth0.com/authorize', 5 | clientId: 'v90UOqUtmib6bTNIm3zHuYboekqoAXwN', 6 | redirectUri: 'http://localhost:8080/redirect', 7 | scope: 'openid profile', 8 | responseType: 'id_token', 9 | accessTokenResponseKey: 'id_token', 10 | additionalAuthorizationParameters: { 11 | nonce: Math.random().toString(), 12 | }, 13 | }); 14 | -------------------------------------------------------------------------------- /examples/react-example/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 3 | 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; 4 | width: 100vw; 5 | height: 100vh; 6 | } 7 | 8 | #root { 9 | width: 100%; 10 | height: 100%; 11 | } -------------------------------------------------------------------------------- /examples/react-example/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import './index.css'; 4 | import 'normalize.css'; 5 | import App from './App'; 6 | import { BrowserRouter } from 'react-router-dom'; 7 | 8 | ReactDOM.render( 9 | 10 | 11 | , 12 | document.getElementById('root'), 13 | ); 14 | -------------------------------------------------------------------------------- /examples/vanilla/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | OAuth2 Window Dot Open - Vanilla JS 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /examples/vanilla/index.js: -------------------------------------------------------------------------------- 1 | /** @type {HTMLElement} */ 2 | const content = document.querySelector('.content'); 3 | 4 | const auth = new OAuth2PopupFlow.OAuth2PopupFlow({ 5 | authorizationUri: 'https://formandfocus.auth0.com/authorize', 6 | clientId: 'v90UOqUtmib6bTNIm3zHuYboekqoAXwN', 7 | redirectUri: 'http://localhost:8080/redirect', 8 | scope: 'openid profile', 9 | responseType: 'id_token', 10 | accessTokenResponseKey: 'id_token', 11 | additionalAuthorizationParameters: { 12 | nonce: Math.random().toString(), 13 | } 14 | }); 15 | 16 | async function main() { 17 | content.innerHTML = ''; 18 | if (auth.loggedIn()) { 19 | const payload = await auth.tokenPayload(); 20 | content.innerText = `Welcome, ${payload.name}!`; 21 | const logoutButton = document.createElement('button'); 22 | logoutButton.innerText = 'Logout'; 23 | logoutButton.addEventListener('click', () => { 24 | auth.logout(); 25 | main(); 26 | }); 27 | content.appendChild(logoutButton); 28 | } else { 29 | const loginButton = document.createElement('button'); 30 | loginButton.innerText = 'Login'; 31 | loginButton.addEventListener('click', async () => { 32 | await auth.tryLoginPopup(); 33 | main(); 34 | }); 35 | content.appendChild(loginButton); 36 | } 37 | } 38 | 39 | main(); -------------------------------------------------------------------------------- /examples/vanilla/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "oauth2-window-dot-open-vanilla-example", 3 | "version": "0.0.0", 4 | "lockfileVersion": 1, 5 | "requires": true, 6 | "dependencies": { 7 | "async": { 8 | "version": "1.5.2", 9 | "resolved": "https://registry.npmjs.org/async/-/async-1.5.2.tgz", 10 | "integrity": "sha1-7GphrlZIDAw8skHJVhjiCJL5Zyo=" 11 | }, 12 | "colors": { 13 | "version": "1.0.3", 14 | "resolved": "https://registry.npmjs.org/colors/-/colors-1.0.3.tgz", 15 | "integrity": "sha1-BDP0TYCWgP3rYO0mDxsMJi6CpAs=" 16 | }, 17 | "corser": { 18 | "version": "2.0.1", 19 | "resolved": "https://registry.npmjs.org/corser/-/corser-2.0.1.tgz", 20 | "integrity": "sha1-jtolLsqrWEDc2XXOuQ2TcMgZ/4c=" 21 | }, 22 | "debug": { 23 | "version": "2.6.9", 24 | "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", 25 | "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", 26 | "requires": { 27 | "ms": "2.0.0" 28 | } 29 | }, 30 | "ecstatic": { 31 | "version": "3.2.0", 32 | "resolved": "https://registry.npmjs.org/ecstatic/-/ecstatic-3.2.0.tgz", 33 | "integrity": "sha512-Goilx/2cfU9vvfQjgtNgc2VmJAD8CasQ6rZDqCd2u4Hsyd/qFET6nBf60jiHodevR3nl3IGzNKtrzPXWP88utQ==", 34 | "requires": { 35 | "he": "^1.1.1", 36 | "mime": "^1.4.1", 37 | "minimist": "^1.1.0", 38 | "url-join": "^2.0.2" 39 | } 40 | }, 41 | "eventemitter3": { 42 | "version": "1.2.0", 43 | "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-1.2.0.tgz", 44 | "integrity": "sha1-HIaZHYFq0eUEdQ5zh0Ik7PO+xQg=" 45 | }, 46 | "he": { 47 | "version": "1.1.1", 48 | "resolved": "https://registry.npmjs.org/he/-/he-1.1.1.tgz", 49 | "integrity": "sha1-k0EP0hsAlzUVH4howvJx80J+I/0=" 50 | }, 51 | "http-proxy": { 52 | "version": "1.16.2", 53 | "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.16.2.tgz", 54 | "integrity": "sha1-Bt/ykpUr9k2+hHH6nfcwZtTzd0I=", 55 | "requires": { 56 | "eventemitter3": "1.x.x", 57 | "requires-port": "1.x.x" 58 | } 59 | }, 60 | "http-server": { 61 | "version": "0.11.1", 62 | "resolved": "https://registry.npmjs.org/http-server/-/http-server-0.11.1.tgz", 63 | "integrity": "sha512-6JeGDGoujJLmhjiRGlt8yK8Z9Kl0vnl/dQoQZlc4oeqaUoAKQg94NILLfrY3oWzSyFaQCVNTcKE5PZ3cH8VP9w==", 64 | "requires": { 65 | "colors": "1.0.3", 66 | "corser": "~2.0.0", 67 | "ecstatic": "^3.0.0", 68 | "http-proxy": "^1.8.1", 69 | "opener": "~1.4.0", 70 | "optimist": "0.6.x", 71 | "portfinder": "^1.0.13", 72 | "union": "~0.4.3" 73 | } 74 | }, 75 | "mime": { 76 | "version": "1.6.0", 77 | "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", 78 | "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==" 79 | }, 80 | "minimist": { 81 | "version": "1.2.0", 82 | "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", 83 | "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=" 84 | }, 85 | "mkdirp": { 86 | "version": "0.5.1", 87 | "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", 88 | "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", 89 | "requires": { 90 | "minimist": "0.0.8" 91 | }, 92 | "dependencies": { 93 | "minimist": { 94 | "version": "0.0.8", 95 | "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", 96 | "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=" 97 | } 98 | } 99 | }, 100 | "ms": { 101 | "version": "2.0.0", 102 | "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", 103 | "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" 104 | }, 105 | "oauth2-popup-flow": { 106 | "version": "0.1.1", 107 | "resolved": "https://registry.npmjs.org/oauth2-popup-flow/-/oauth2-popup-flow-0.1.1.tgz", 108 | "integrity": "sha512-3ivu8clVwqfZAGVu9uzLeW56fhQT5sDslTqp2q1Naujx7GmC5Ibuo5uXM8yjPvCnhmo7k/ZS4zZcht+TsH3+nw==" 109 | }, 110 | "opener": { 111 | "version": "1.4.3", 112 | "resolved": "https://registry.npmjs.org/opener/-/opener-1.4.3.tgz", 113 | "integrity": "sha1-XG2ixdflgx6P+jlklQ+NZnSskLg=" 114 | }, 115 | "optimist": { 116 | "version": "0.6.1", 117 | "resolved": "https://registry.npmjs.org/optimist/-/optimist-0.6.1.tgz", 118 | "integrity": "sha1-2j6nRob6IaGaERwybpDrFaAZZoY=", 119 | "requires": { 120 | "minimist": "~0.0.1", 121 | "wordwrap": "~0.0.2" 122 | }, 123 | "dependencies": { 124 | "minimist": { 125 | "version": "0.0.10", 126 | "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.10.tgz", 127 | "integrity": "sha1-3j+YVD2/lggr5IrRoMfNqDYwHc8=" 128 | } 129 | } 130 | }, 131 | "portfinder": { 132 | "version": "1.0.13", 133 | "resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.13.tgz", 134 | "integrity": "sha1-uzLs2HwnEErm7kS1o8y/Drsa7ek=", 135 | "requires": { 136 | "async": "^1.5.2", 137 | "debug": "^2.2.0", 138 | "mkdirp": "0.5.x" 139 | } 140 | }, 141 | "qs": { 142 | "version": "2.3.3", 143 | "resolved": "https://registry.npmjs.org/qs/-/qs-2.3.3.tgz", 144 | "integrity": "sha1-6eha2+ddoLvkyOBHaghikPhjtAQ=" 145 | }, 146 | "requires-port": { 147 | "version": "1.0.0", 148 | "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", 149 | "integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=" 150 | }, 151 | "union": { 152 | "version": "0.4.6", 153 | "resolved": "https://registry.npmjs.org/union/-/union-0.4.6.tgz", 154 | "integrity": "sha1-GY+9rrolTniLDvy2MLwR8kopWeA=", 155 | "requires": { 156 | "qs": "~2.3.3" 157 | } 158 | }, 159 | "url-join": { 160 | "version": "2.0.5", 161 | "resolved": "https://registry.npmjs.org/url-join/-/url-join-2.0.5.tgz", 162 | "integrity": "sha1-WvIvGMBSoACkjXuCxenC4v7tpyg=" 163 | }, 164 | "wordwrap": { 165 | "version": "0.0.3", 166 | "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.3.tgz", 167 | "integrity": "sha1-o9XabNXAvAAI03I0u68b7WMFkQc=" 168 | } 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /examples/vanilla/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "oauth2-window-dot-open-vanilla-example", 3 | "version": "0.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "http-server", 8 | "test": "echo \"Error: no test specified\" && exit 1" 9 | }, 10 | "author": "Rico Kahler", 11 | "license": "MIT", 12 | "dependencies": { 13 | "http-server": "^0.11.1", 14 | "oauth2-popup-flow": "^0.1.1" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /examples/vanilla/redirect.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | OAuth2 Window Dot Open - Redirect Loading 4 | 5 | 6 | 7 |
8 | Logging you in... 9 |
Feel free to close this window if it doesn't go away automatically.
10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /examples/vanilla/redirect.js: -------------------------------------------------------------------------------- 1 | const auth = new OAuth2PopupFlow.OAuth2PopupFlow({ 2 | authorizationUri: 'https://formandfocus.auth0.com/authorize', 3 | clientId: 'v90UOqUtmib6bTNIm3zHuYboekqoAXwN', 4 | redirectUri: 'http://localhost:8080/redirect', 5 | scope: 'openid profile', 6 | responseType: 'id_token', 7 | accessTokenResponseKey: 'id_token', 8 | }); 9 | 10 | auth.handleRedirect(); -------------------------------------------------------------------------------- /examples/vanilla/styles.css: -------------------------------------------------------------------------------- 1 | * { 2 | font-family: sans-serif; 3 | color: peachpuff; 4 | } 5 | 6 | body { 7 | margin: 0; 8 | padding: 0; 9 | } 10 | 11 | .content { 12 | display: flex; 13 | flex-direction: column; 14 | justify-content: center; 15 | align-items: center; 16 | min-height: 100vh; 17 | min-width: 100vw; 18 | 19 | font-size: 2.618rem; 20 | font-weight: 500; 21 | background-color: palevioletred; 22 | } 23 | 24 | .sub { 25 | font-size: 1rem; 26 | margin: 1rem; 27 | } 28 | 29 | button { 30 | background-color: transparent; 31 | border: 0.1rem solid peachpuff; 32 | font-size: 1.618rem; 33 | padding: 1rem; 34 | margin: 1rem; 35 | outline: none; 36 | } 37 | 38 | button:hover { 39 | background-color: peachpuff; 40 | color: palevioletred; 41 | } 42 | 43 | button:active { 44 | background-color: papayawhip; 45 | } 46 | -------------------------------------------------------------------------------- /license.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Rico Kahler 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 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "oauth2-popup-flow", 3 | "version": "1.1.0", 4 | "description": "A very simple oauth2 implicit flow library that uses window.open.", 5 | "main": "./index.js", 6 | "module": "./index.esm.js", 7 | "sideEffects": false, 8 | "scripts": { 9 | "test": "jest", 10 | "build": "rm -rf dist && tsc && rollup -c && cp package.json ./dist && cp readme.md ./dist", 11 | "docs": "rm -rf docs && typedoc --out ./docs ./src/index.ts" 12 | }, 13 | "author": "Rico Kahler", 14 | "license": "MIT", 15 | "repository": { 16 | "type": "git", 17 | "url": "https://github.com/ricokahler/oauth2-popup-flow.git" 18 | }, 19 | "devDependencies": { 20 | "@babel/preset-env": "^7.9.0", 21 | "@babel/preset-typescript": "^7.9.0", 22 | "@rollup/plugin-typescript": "^4.0.0", 23 | "@types/jest": "^26.0.3", 24 | "jest": "^26.1.0", 25 | "jsdom": "^16.2.2", 26 | "rollup": "^2.3.3", 27 | "tslib": "^2.0.0", 28 | "typedoc": "^0.17.4", 29 | "typescript": "^3.8.3" 30 | }, 31 | "dependencies": {} 32 | } 33 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | ``` 2 | ____ _ _ ___ 3 | / __ \ /\ | | | | |__ \ 4 | | | | | / \ _ _| |_| |__ ) | 5 | | | | |/ /\ \| | | | __| '_ \ / / 6 | | |__| / ____ \ |_| | |_| | | |/ /_ 7 | \____/_/ \_\__,_|\__|_| |_|____| 8 | 9 | /$$$$$$$ 10 | | $$__ $$ 11 | | $$ \ $$ /$$$$$$ /$$$$$$ /$$ /$$ /$$$$$$ 12 | | $$$$$$$//$$__ $$ /$$__ $$| $$ | $$ /$$__ $$ 13 | | $$____/| $$ \ $$| $$ \ $$| $$ | $$| $$ \ $$ 14 | | $$ | $$ | $$| $$ | $$| $$ | $$| $$ | $$ 15 | | $$ | $$$$$$/| $$$$$$$/| $$$$$$/| $$$$$$$/ 16 | |__/ \______/ | $$____/ \______/ | $$____/ 17 | | $$ | $$ 18 | | $$ | $$ ______ _ 19 | |__/ |__/ | ____| | 20 | | |__ | | _____ __ 21 | | __| | |/ _ \ \ /\ / / 22 | | | | | (_) \ V V / 23 | |_| |_|\___/ \_/\_/ 24 | ``` 25 | 26 | [![codecov](https://codecov.io/gh/ricokahler/oauth2-popup-flow/branch/master/graph/badge.svg)](https://codecov.io/gh/ricokahler/oauth2-popup-flow) 27 | 28 | ### A very simple oauth2 implicit grant flow library
with no dependencies that uses `window.open`. 29 | 30 | - Simplicity as a feature—only 209 SLOC. 31 | - No dependencies. 32 | - Easily integrates with React, Angular, Vue etc. 33 | - Never interrupt or reload the state of your client to login. 34 | - To get a token, call `oauth2PopupFlow.token()` which returns a `Promise` of the token. 35 | - To get the payload, call `oauth2PopupFlow.tokenPayload()` which returns a `Promise`. 36 | - Statically typed API via Typescript for use within Javascript or Typescript. 37 | 38 | ### Why the popup? 39 | 40 | If the user isn't logged in, the typical OAuth2 implicit grant flow forwards the user to the authorization server's login page (separate from your app) and then redirects them back. The issue with this is that it requires the app to be reloaded in order to grab a token. This reload complicates your application and may result in lost work due to the app reloading. 41 | 42 | The popup is a simple solution that allows the user to load the hosted login page and come back to your app while keeping the state of your application. 43 | 44 | ### This library is great if: 45 | 46 | - You already use the implicit grant 47 | - Your authorization server typically doesn't prompt the user to login 48 | - You want the user to automatically be logged in and authenticated in your application 49 | 50 | ## Usage 51 | 52 | ``` 53 | npm install --save oauth2-popup-flow 54 | ``` 55 | 56 | ```ts 57 | import { OAuth2PopupFlow } from 'oauth2-popup-flow'; 58 | 59 | // create a type for the payload of the token 60 | interface TokenPayload { 61 | exp: number; 62 | other: string; 63 | stuff: string; 64 | username: string; 65 | } 66 | 67 | // create an instance of `OAuth2PopupFlow` 68 | export const auth = new OAuth2PopupFlow({ 69 | authorizationUri: 'https://example.com/oauth/authorize', 70 | clientId: 'YOUR_CLIENT_ID', 71 | redirectUri: 'http://localhost:8080/redirect', 72 | scope: 'openid profile', 73 | }); 74 | 75 | // opens the login popup 76 | // if the user is already logged in, it won't open the popup 77 | auth.tryLoginPopup().then(result => { 78 | if (result === 'ALREADY_LOGGED_IN') { 79 | // ... 80 | } else if (result === 'POPUP_FAILED') { 81 | // ... 82 | } else if (result === 'SUCCESS') { 83 | // ... 84 | } 85 | }); 86 | 87 | // synchronously returns whether or not the user is logged in 88 | const loggedIn = auth.loggedIn(); 89 | 90 | async function someAsyncFunction() { 91 | // open the popup 92 | auth.tryLoginPopup(); 93 | // await until authorized 94 | const token = await auth.token(); 95 | 96 | const response = await fetch('https://example.com', { 97 | method: 'POST', 98 | headers: new Headers({ 99 | Authorization: `Bearer ${token}`, 100 | }), 101 | }); 102 | } 103 | 104 | async function getInfoFromToken() { 105 | // open the popup 106 | auth.tryLoginPopup(); 107 | // returns the decoded payload of the token when authorized 108 | const payload = await auth.tokenPayload(); 109 | return payload.username; 110 | } 111 | 112 | someAsyncFunction(); 113 | getInfoFromToken().then(username => console.log({ username })); 114 | 115 | // also implements EventTarget so you can add event listeners for `login` and `logout` 116 | auth.addEventListener('login', () => { 117 | console.log('user was logged in'); 118 | }); 119 | 120 | auth.addEventListener('logout', () => { 121 | console.log('user was logged out'); 122 | }); 123 | ``` 124 | 125 | [Check out the API docs for more info](https://oauth2-popup-flow.netlify.com/interfaces/_index_.oauth2popupflowoptions.html) 126 | 127 | ### Examples (work-in-progress, contributions welcome) 128 | 129 | - [No framework](./examples/vanilla) 130 | - [Angular](./examples/angular) 131 | - [React + React Router](./examples/react-example) 132 | 133 | ### Requirements 134 | 135 | - [`String.prototype.startsWith`][0] (you may need a polyfill if you're targeting IE etc) 136 | 137 | [0]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/startsWith 138 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | const typescript = require('@rollup/plugin-typescript'); 2 | const tsconfig = require('./tsconfig.json'); 3 | 4 | const { compilerOptions, exclude } = tsconfig; 5 | const { declaration, emitDeclarationOnly, outDir, ...rest } = compilerOptions; 6 | 7 | module.exports = [ 8 | { 9 | input: './src/index.ts', 10 | output: { 11 | file: './dist/index.js', 12 | format: 'umd', 13 | name: 'OAuth2PopupFlow', 14 | sourcemap: true, 15 | }, 16 | plugins: [typescript({ ...rest, exclude, tsconfig: false })], 17 | }, 18 | { 19 | input: './src/index.ts', 20 | output: { 21 | file: './dist/index.esm.js', 22 | format: 'esm', 23 | sourcemap: true, 24 | }, 25 | plugins: [ 26 | typescript({ 27 | ...rest, 28 | exclude, 29 | tsconfig: false, 30 | target: 'es2015', 31 | module: 'es2015', 32 | }), 33 | ], 34 | }, 35 | ]; 36 | -------------------------------------------------------------------------------- /src/index.test.ts: -------------------------------------------------------------------------------- 1 | import { OAuth2PopupFlow } from './'; 2 | 3 | interface ExampleTokenPayload { 4 | exp: number; 5 | foo: string; 6 | bar: number; 7 | } 8 | 9 | function createTestStorage() { 10 | const _storage: { [key: string]: string | undefined | null } = {}; 11 | return { 12 | clear: () => { 13 | for (const key of Object.keys(_storage)) { 14 | delete _storage[key]; 15 | } 16 | }, 17 | getItem: (key: string) => _storage[key] || null, 18 | key: (index: number) => Object.keys(_storage)[index] || null, 19 | length: Object.keys(_storage).length, 20 | removeItem: (key: string) => { 21 | delete _storage[key]; 22 | }, 23 | setItem: (key: string, value: string) => { 24 | _storage[key] = value; 25 | }, 26 | _storage, 27 | }; 28 | } 29 | 30 | describe('OAuth2PopupFlow', () => { 31 | describe('jsonParseOrUndefined', () => { 32 | it('returns parsed JSON when valid', () => { 33 | const validJson = '{"a": "some value", "b": 5}'; 34 | const parsed = OAuth2PopupFlow.jsonParseOrUndefined<{ 35 | a: string; 36 | b: number; 37 | }>(validJson)!; 38 | expect(parsed).toBeDefined(); 39 | expect(parsed.a).toBe('some value'); 40 | expect(parsed.b).toBe(5); 41 | }); 42 | it('returns undefined when the JSON is invalid', () => { 43 | const invalidJson = 'this aint json'; 44 | const parsed = OAuth2PopupFlow.jsonParseOrUndefined(invalidJson); 45 | expect(parsed).toBeUndefined(); 46 | }); 47 | }); 48 | 49 | describe('time', () => { 50 | it('calls `setTimeout` and returns `TIMER`', async () => { 51 | function fiveMilliseconds() { 52 | return new Promise<5>((resolve) => setTimeout(() => resolve(5), 5)); 53 | } 54 | 55 | const race = await Promise.race([ 56 | OAuth2PopupFlow.time(10), 57 | fiveMilliseconds(), 58 | ]); 59 | expect(race).toBe(5); 60 | 61 | const otherRace = await Promise.race([ 62 | OAuth2PopupFlow.time(0), 63 | fiveMilliseconds(), 64 | ]); 65 | expect(otherRace).toBe('TIMER'); 66 | }); 67 | }); 68 | 69 | describe('decodeUri', () => { 70 | it('calls `decodeURIComponent` and returns its result', () => { 71 | const result = OAuth2PopupFlow.decodeUri('hello%20world'); 72 | expect(result).toBe('hello world'); 73 | }); 74 | it('catches `decodeURIComponent` and returns the original string', () => { 75 | const notParsable = '%'; 76 | const result = OAuth2PopupFlow.decodeUri(notParsable); 77 | expect(result).toBe(notParsable); 78 | }); 79 | }); 80 | 81 | describe('encodeObjectToUri', () => { 82 | it('encodes plain ole javascript objects of strings to a URI component', () => { 83 | const javascriptObject = { foo: 'some value', bar: 'some other value' }; 84 | const encoded = OAuth2PopupFlow.encodeObjectToUri(javascriptObject); 85 | expect(encoded).toBe('foo=some%20value&bar=some%20other%20value'); 86 | }); 87 | }); 88 | 89 | describe('decodeUriToObject', () => { 90 | it('decodes a URI into an object of strings', () => { 91 | const uri = 'foo=some%20value&bar=some%20other%20value'; 92 | const decoded = OAuth2PopupFlow.decodeUriToObject(uri); 93 | expect(decoded).toEqual({ foo: 'some value', bar: 'some other value' }); 94 | }); 95 | }); 96 | 97 | describe('constructor', () => { 98 | it('creates instances from the `OAuth2PopupFlowOptions` object', () => { 99 | function beforePopup() {} 100 | function afterResponse() {} 101 | function tokenValidator() { 102 | return true; 103 | } 104 | const additionalAuthorizationParameters = { foo: 'bar' }; 105 | 106 | const storage = createTestStorage(); 107 | 108 | const options = { 109 | accessTokenResponseKey: 'test_response_key', 110 | accessTokenStorageKey: 'test_storage_key', 111 | additionalAuthorizationParameters, 112 | authorizationUri: 'http://example.com/oauth/authorize', 113 | beforePopup, 114 | clientId: 'test_client_id', 115 | pollingTime: Math.random(), 116 | redirectUri: 'http://localhost:8080/redirect', 117 | responseType: 'test_token', 118 | scope: 'test scope', 119 | storage, 120 | tokenValidator, 121 | afterResponse, 122 | }; 123 | 124 | const auth = new OAuth2PopupFlow(options); 125 | 126 | expect(auth.accessTokenResponseKey).toBe(options.accessTokenResponseKey); 127 | expect(auth.accessTokenStorageKey).toBe(options.accessTokenStorageKey); 128 | expect(auth.additionalAuthorizationParameters).toBe( 129 | additionalAuthorizationParameters, 130 | ); 131 | expect(auth.authorizationUri).toBe(options.authorizationUri); 132 | expect(auth.beforePopup).toBe(beforePopup); 133 | expect(auth.clientId).toBe(options.clientId); 134 | expect(auth.pollingTime).toBe(options.pollingTime); 135 | expect(auth.redirectUri).toBe(options.redirectUri); 136 | expect(auth.responseType).toBe(options.responseType); 137 | expect(auth.scope).toBe(options.scope); 138 | expect(auth.storage).toBe(storage); 139 | expect(auth.tokenValidator).toBe(tokenValidator); 140 | expect(auth.afterResponse).toBe(afterResponse); 141 | }); 142 | it('uses the default `responseType` of `token` when none is present', () => { 143 | const options = { 144 | authorizationUri: 'http://example.com/oauth/authorize', 145 | clientId: 'test_client_id', 146 | redirectUri: 'http://localhost:8080/redirect', 147 | scope: 'test scope', 148 | }; 149 | 150 | const auth = new OAuth2PopupFlow(options); 151 | 152 | expect(auth.responseType).toBe('token'); 153 | }); 154 | it('uses the default `accessTokenStorageKey` of `token` when none is present', () => { 155 | const options = { 156 | authorizationUri: 'http://example.com/oauth/authorize', 157 | clientId: 'test_client_id', 158 | redirectUri: 'http://localhost:8080/redirect', 159 | scope: 'test scope', 160 | }; 161 | 162 | const auth = new OAuth2PopupFlow(options); 163 | 164 | expect(auth.accessTokenStorageKey).toBe('token'); 165 | }); 166 | it('uses the default `accessTokenResponseKey` of `access_token` when none is present', () => { 167 | const options = { 168 | authorizationUri: 'http://example.com/oauth/authorize', 169 | clientId: 'test_client_id', 170 | redirectUri: 'http://localhost:8080/redirect', 171 | scope: 'test scope', 172 | }; 173 | 174 | const auth = new OAuth2PopupFlow(options); 175 | 176 | expect(auth.accessTokenResponseKey).toBe('access_token'); 177 | }); 178 | it('uses the default `storage` of `window.localStorage` when none is present', () => { 179 | const options = { 180 | authorizationUri: 'http://example.com/oauth/authorize', 181 | clientId: 'test_client_id', 182 | redirectUri: 'http://localhost:8080/redirect', 183 | scope: 'test scope', 184 | }; 185 | 186 | const auth = new OAuth2PopupFlow(options); 187 | 188 | expect(auth.storage).toBe(window.localStorage); 189 | }); 190 | it('uses the default `pollingTime` of `200` when none is present', () => { 191 | const options = { 192 | authorizationUri: 'http://example.com/oauth/authorize', 193 | clientId: 'test_client_id', 194 | redirectUri: 'http://localhost:8080/redirect', 195 | responseType: 'test_token', 196 | scope: 'test scope', 197 | }; 198 | 199 | const auth = new OAuth2PopupFlow(options); 200 | 201 | expect(auth.pollingTime).toBe(200); 202 | }); 203 | }); 204 | 205 | describe('_rawToken', () => { 206 | it('gets the raw token from storage', () => { 207 | const storage = createTestStorage(); 208 | 209 | const auth = new OAuth2PopupFlow({ 210 | authorizationUri: 'http://example.com/oauth/authorize', 211 | clientId: 'some_test_client', 212 | redirectUri: 'http://localhost:8080/redirect', 213 | scope: 'openid profile', 214 | storage, 215 | }); 216 | 217 | storage._storage.token = 'test_token'; 218 | 219 | expect(auth['_rawToken']).toBe('test_token'); 220 | }); 221 | it('returns `undefined` if the value in storage is falsy', () => { 222 | const storage = createTestStorage(); 223 | 224 | const auth = new OAuth2PopupFlow({ 225 | authorizationUri: 'http://example.com/oauth/authorize', 226 | clientId: 'some_test_client', 227 | redirectUri: 'http://localhost:8080/redirect', 228 | scope: 'openid profile', 229 | storage, 230 | }); 231 | 232 | storage._storage.token = ''; 233 | expect(auth['_rawToken']).toBeUndefined(); 234 | 235 | storage._storage.token = null; 236 | expect(auth['_rawToken']).toBeUndefined(); 237 | }); 238 | it("doesn't allow `null` or `undefined` to be assigned to storage but allows strings", () => { 239 | const storage = createTestStorage(); 240 | 241 | const auth = new OAuth2PopupFlow({ 242 | authorizationUri: 'http://example.com/oauth/authorize', 243 | clientId: 'some_test_client', 244 | redirectUri: 'http://localhost:8080/redirect', 245 | scope: 'openid profile', 246 | storage, 247 | }); 248 | 249 | storage._storage.token = 'initial value'; 250 | 251 | auth['_rawToken'] = undefined; 252 | expect(storage._storage.token).toBe('initial value'); 253 | 254 | (auth as any)['_rawToken'] = null; 255 | expect(storage._storage.token).toBe('initial value'); 256 | 257 | auth['_rawToken'] = ''; 258 | expect(storage._storage.token).toBe(''); 259 | 260 | auth['_rawToken'] = 'something'; 261 | expect(storage._storage.token).toBe('something'); 262 | }); 263 | }); 264 | 265 | describe('_rawTokenPayload', () => { 266 | it('returns `undefined` if the `_rawToken` is falsy', () => { 267 | const storage = createTestStorage(); 268 | 269 | const auth = new OAuth2PopupFlow({ 270 | authorizationUri: 'http://example.com/oauth/authorize', 271 | clientId: 'some_test_client', 272 | redirectUri: 'http://localhost:8080/redirect', 273 | scope: 'openid profile', 274 | storage, 275 | }); 276 | 277 | storage._storage.token = undefined; 278 | expect(auth['_rawTokenPayload']).toBeUndefined(); 279 | 280 | storage._storage.token = null; 281 | expect(auth['_rawTokenPayload']).toBeUndefined(); 282 | 283 | storage._storage.token = ''; 284 | expect(auth['_rawTokenPayload']).toBeUndefined(); 285 | }); 286 | it("returns `undefined` if it couldn't find the encoded payload in the token", () => { 287 | const storage = createTestStorage(); 288 | 289 | const auth = new OAuth2PopupFlow({ 290 | authorizationUri: 'http://example.com/oauth/authorize', 291 | clientId: 'some_test_client', 292 | redirectUri: 'http://localhost:8080/redirect', 293 | scope: 'openid profile', 294 | storage, 295 | }); 296 | 297 | storage._storage.token = 'non-proper JWT'; 298 | expect(auth['_rawTokenPayload']).toBeUndefined(); 299 | }); 300 | it("returns `undefined` if it couldn't parse the JSON in the encoded payload", () => { 301 | const storage = createTestStorage(); 302 | 303 | const auth = new OAuth2PopupFlow({ 304 | authorizationUri: 'http://example.com/oauth/authorize', 305 | clientId: 'some_test_client', 306 | redirectUri: 'http://localhost:8080/redirect', 307 | scope: 'openid profile', 308 | storage, 309 | }); 310 | 311 | storage._storage.token = [ 312 | 'non proper JWT', 313 | 'this is the payload section', 314 | 'this is the signature section', 315 | ].join('.'); 316 | 317 | expect(auth['_rawTokenPayload']).toBeUndefined(); 318 | }); 319 | it('returns a proper decoded payload', () => { 320 | const storage = createTestStorage(); 321 | 322 | const auth = new OAuth2PopupFlow({ 323 | authorizationUri: 'http://example.com/oauth/authorize', 324 | clientId: 'some_test_client', 325 | redirectUri: 'http://localhost:8080/redirect', 326 | scope: 'openid profile', 327 | storage, 328 | }); 329 | 330 | const examplePayload = { 331 | foo: 'something', 332 | bar: 5, 333 | exp: Math.floor(new Date().getTime() / 1000), 334 | }; 335 | 336 | storage._storage.token = [ 337 | 'blah blah header', 338 | window.btoa(JSON.stringify(examplePayload)), 339 | 'this is the signature section', 340 | ].join('.'); 341 | 342 | expect(auth['_rawTokenPayload']).toEqual(examplePayload); 343 | }); 344 | }); 345 | 346 | describe('loggedIn', () => { 347 | it('returns `false` if the `_rawTokenPayload` is undefined', () => { 348 | const storage = createTestStorage(); 349 | 350 | const auth = new OAuth2PopupFlow({ 351 | authorizationUri: 'http://example.com/oauth/authorize', 352 | clientId: 'some_test_client', 353 | redirectUri: 'http://localhost:8080/redirect', 354 | scope: 'openid profile', 355 | storage, 356 | }); 357 | 358 | storage._storage.token = undefined; 359 | 360 | expect(auth.loggedIn()).toBe(false); 361 | }); 362 | it('passes through the `tokenValidator` with `true`', () => { 363 | const storage = createTestStorage(); 364 | const examplePayload = { 365 | foo: 'something', 366 | bar: 5, 367 | exp: Math.floor(new Date().getTime() / 1000) + 1000, 368 | }; 369 | const exampleToken = [ 370 | 'blah blah header', 371 | window.btoa(JSON.stringify(examplePayload)), 372 | 'this is the signature section', 373 | ].join('.'); 374 | storage._storage.token = exampleToken; 375 | 376 | let tokenValidatorCalled = false; 377 | 378 | const auth = new OAuth2PopupFlow({ 379 | authorizationUri: 'http://example.com/oauth/authorize', 380 | clientId: 'some_test_client', 381 | redirectUri: 'http://localhost:8080/redirect', 382 | scope: 'openid profile', 383 | storage, 384 | tokenValidator: ({ token, payload }) => { 385 | expect(token).toBe(exampleToken); 386 | expect(payload).toEqual(examplePayload); 387 | tokenValidatorCalled = true; 388 | return true; 389 | }, 390 | }); 391 | 392 | expect(auth.loggedIn()).toBe(true); 393 | expect(tokenValidatorCalled).toBe(true); 394 | }); 395 | it('returns `false` if there is a `tokenValidator` and that returns false', () => { 396 | const storage = createTestStorage(); 397 | const examplePayload = { 398 | foo: 'something', 399 | bar: 5, 400 | exp: Math.floor(new Date().getTime() / 1000), 401 | }; 402 | const exampleToken = [ 403 | 'blah blah header', 404 | window.btoa(JSON.stringify(examplePayload)), 405 | 'this is the signature section', 406 | ].join('.'); 407 | storage._storage.token = exampleToken; 408 | 409 | let tokenValidatorCalled = false; 410 | 411 | const auth = new OAuth2PopupFlow({ 412 | authorizationUri: 'http://example.com/oauth/authorize', 413 | clientId: 'some_test_client', 414 | redirectUri: 'http://localhost:8080/redirect', 415 | scope: 'openid profile', 416 | storage, 417 | tokenValidator: ({ token, payload }) => { 418 | expect(token).toBe(exampleToken); 419 | expect(payload).toEqual(examplePayload); 420 | tokenValidatorCalled = true; 421 | return false; 422 | }, 423 | }); 424 | 425 | expect(auth.loggedIn()).toBe(false); 426 | expect(tokenValidatorCalled).toBe(true); 427 | }); 428 | it('returns `false` if the `exp` in the payload is falsy', () => { 429 | const storage = createTestStorage(); 430 | const examplePayload = { 431 | foo: 'something', 432 | bar: 5, 433 | exp: 0, 434 | }; 435 | const exampleToken = [ 436 | 'blah blah header', 437 | window.btoa(JSON.stringify(examplePayload)), 438 | 'this is the signature section', 439 | ].join('.'); 440 | storage._storage.token = exampleToken; 441 | 442 | const auth = new OAuth2PopupFlow({ 443 | authorizationUri: 'http://example.com/oauth/authorize', 444 | clientId: 'some_test_client', 445 | redirectUri: 'http://localhost:8080/redirect', 446 | scope: 'openid profile', 447 | storage, 448 | }); 449 | 450 | expect(auth.loggedIn()).toBe(false); 451 | }); 452 | it('returns `false` if the token is expired', () => { 453 | const storage = createTestStorage(); 454 | const examplePayload = { 455 | foo: 'something', 456 | bar: 5, 457 | exp: Math.floor(new Date().getTime() / 1000) - 1000, 458 | }; 459 | const exampleToken = [ 460 | 'blah blah header', 461 | window.btoa(JSON.stringify(examplePayload)), 462 | 'this is the signature section', 463 | ].join('.'); 464 | storage._storage.token = exampleToken; 465 | 466 | const auth = new OAuth2PopupFlow({ 467 | authorizationUri: 'http://example.com/oauth/authorize', 468 | clientId: 'some_test_client', 469 | redirectUri: 'http://localhost:8080/redirect', 470 | scope: 'openid profile', 471 | storage, 472 | }); 473 | 474 | expect(auth.loggedIn()).toBe(false); 475 | }); 476 | it('returns `true` if the token is good', () => { 477 | const storage = createTestStorage(); 478 | const examplePayload = { 479 | foo: 'something', 480 | bar: 5, 481 | exp: Math.floor(new Date().getTime() / 1000) + 1000, 482 | }; 483 | const exampleToken = [ 484 | 'blah blah header', 485 | window.btoa(JSON.stringify(examplePayload)), 486 | 'this is the signature section', 487 | ].join('.'); 488 | storage._storage.token = exampleToken; 489 | 490 | const auth = new OAuth2PopupFlow({ 491 | authorizationUri: 'http://example.com/oauth/authorize', 492 | clientId: 'some_test_client', 493 | redirectUri: 'http://localhost:8080/redirect', 494 | scope: 'openid profile', 495 | storage, 496 | }); 497 | 498 | expect(auth.loggedIn()).toBe(true); 499 | }); 500 | }); 501 | 502 | describe('tokenExpired', () => { 503 | it('returns `false` if the `_rawTokenPayload` is undefined', () => { 504 | const storage = createTestStorage(); 505 | 506 | const auth = new OAuth2PopupFlow({ 507 | authorizationUri: 'http://example.com/oauth/authorize', 508 | clientId: 'some_test_client', 509 | redirectUri: 'http://localhost:8080/redirect', 510 | scope: 'openid profile', 511 | storage, 512 | }); 513 | 514 | storage._storage.token = undefined; 515 | 516 | expect(auth.tokenExpired()).toBe(false); 517 | }); 518 | it('returns `false` if the `exp` in the payload is falsy', () => { 519 | const storage = createTestStorage(); 520 | const examplePayload = { 521 | foo: 'something', 522 | bar: 5, 523 | exp: 0, 524 | }; 525 | const exampleToken = [ 526 | 'blah blah header', 527 | window.btoa(JSON.stringify(examplePayload)), 528 | 'this is the signature section', 529 | ].join('.'); 530 | storage._storage.token = exampleToken; 531 | 532 | const auth = new OAuth2PopupFlow({ 533 | authorizationUri: 'http://example.com/oauth/authorize', 534 | clientId: 'some_test_client', 535 | redirectUri: 'http://localhost:8080/redirect', 536 | scope: 'openid profile', 537 | storage, 538 | }); 539 | 540 | expect(auth.tokenExpired()).toBe(false); 541 | }); 542 | it('returns `false` if the token is not expired', () => { 543 | const storage = createTestStorage(); 544 | const examplePayload = { 545 | foo: 'something', 546 | bar: 5, 547 | exp: Math.floor(new Date().getTime() / 1000) + 1000, 548 | }; 549 | const exampleToken = [ 550 | 'blah blah header', 551 | window.btoa(JSON.stringify(examplePayload)), 552 | 'this is the signature section', 553 | ].join('.'); 554 | storage._storage.token = exampleToken; 555 | 556 | const auth = new OAuth2PopupFlow({ 557 | authorizationUri: 'http://example.com/oauth/authorize', 558 | clientId: 'some_test_client', 559 | redirectUri: 'http://localhost:8080/redirect', 560 | scope: 'openid profile', 561 | storage, 562 | }); 563 | 564 | expect(auth.tokenExpired()).toBe(false); 565 | }); 566 | it('returns `true` if the token is expired', () => { 567 | const storage = createTestStorage(); 568 | const examplePayload = { 569 | foo: 'something', 570 | bar: 5, 571 | exp: Math.floor(new Date().getTime() / 1000) - 1000, 572 | }; 573 | const exampleToken = [ 574 | 'blah blah header', 575 | window.btoa(JSON.stringify(examplePayload)), 576 | 'this is the signature section', 577 | ].join('.'); 578 | storage._storage.token = exampleToken; 579 | 580 | const auth = new OAuth2PopupFlow({ 581 | authorizationUri: 'http://example.com/oauth/authorize', 582 | clientId: 'some_test_client', 583 | redirectUri: 'http://localhost:8080/redirect', 584 | scope: 'openid profile', 585 | storage, 586 | }); 587 | 588 | expect(auth.tokenExpired()).toBe(true); 589 | }); 590 | }); 591 | 592 | describe('logout', () => { 593 | it('should remove the token from storage', () => { 594 | const storage = createTestStorage(); 595 | const examplePayload = { 596 | foo: 'something', 597 | bar: 5, 598 | exp: Math.floor(new Date().getTime() / 1000) + 1000, 599 | }; 600 | const exampleToken = [ 601 | 'blah blah header', 602 | window.btoa(JSON.stringify(examplePayload)), 603 | 'this is the signature section', 604 | ].join('.'); 605 | storage._storage.token = exampleToken; 606 | 607 | const auth = new OAuth2PopupFlow({ 608 | authorizationUri: 'http://example.com/oauth/authorize', 609 | clientId: 'some_test_client', 610 | redirectUri: 'http://localhost:8080/redirect', 611 | scope: 'openid profile', 612 | storage, 613 | }); 614 | 615 | expect(auth.loggedIn()).toBe(true); 616 | auth.logout(); 617 | expect(auth.loggedIn()).toBe(false); 618 | }); 619 | 620 | it('should dispatch a logout event', () => { 621 | const storage = createTestStorage(); 622 | const examplePayload = { 623 | foo: 'something', 624 | bar: 5, 625 | exp: Math.floor(new Date().getTime() / 1000) + 1000, 626 | }; 627 | const exampleToken = [ 628 | 'blah blah header', 629 | window.btoa(JSON.stringify(examplePayload)), 630 | 'this is the signature section', 631 | ].join('.'); 632 | storage._storage.token = exampleToken; 633 | 634 | const auth = new OAuth2PopupFlow({ 635 | authorizationUri: 'http://example.com/oauth/authorize', 636 | clientId: 'some_test_client', 637 | redirectUri: 'http://localhost:8080/redirect', 638 | scope: 'openid profile', 639 | storage, 640 | }); 641 | const handler = jest.fn(); 642 | auth.addEventListener('logout', handler); 643 | 644 | expect(auth.loggedIn()).toBe(true); 645 | auth.logout(); 646 | expect(auth.loggedIn()).toBe(false); 647 | expect(handler).toBeCalledTimes(1); 648 | }); 649 | }); 650 | 651 | describe('handleRedirect', () => { 652 | it("returns early with `REDIRECT_URI_MISMATCH` if location doesn't match the redirect", () => { 653 | const storage = createTestStorage(); 654 | 655 | const options = { 656 | authorizationUri: 'http://example.com/oauth/authorize', 657 | clientId: 'some_test_client', 658 | redirectUri: 'http://localhost:8080/redirect', 659 | scope: 'openid profile', 660 | storage, 661 | }; 662 | 663 | const auth = new OAuth2PopupFlow(options); 664 | 665 | window.location.hash = 'something%20else'; 666 | 667 | const result = auth.handleRedirect(); 668 | expect(result).toBe('REDIRECT_URI_MISMATCH'); 669 | }); 670 | it('returns early with `FALSY_HASH` if the hash is falsy', () => { 671 | const storage = createTestStorage(); 672 | 673 | const options = { 674 | authorizationUri: 'http://example.com/oauth/authorize', 675 | clientId: 'some_test_client', 676 | redirectUri: '', 677 | scope: 'openid profile', 678 | storage, 679 | }; 680 | 681 | const auth = new OAuth2PopupFlow(options); 682 | 683 | window.location.hash = ''; 684 | 685 | const result = auth.handleRedirect(); 686 | expect(result).toBe('FALSY_HASH'); 687 | }); 688 | // // this test won't pass because the js-dom environment will always add the `#` to the string 689 | // it('returns early with `NO_HASH_MATCH` if hash doesn\'t match /#(.*)/', () => { 690 | // const storage = createTestStorage(); 691 | 692 | // const options = { 693 | // authorizationUri: 'http://example.com/oauth/authorize', 694 | // clientId: 'some_test_client', 695 | // redirectUri: '', 696 | // scope: 'openid profile', 697 | // storage, 698 | // }; 699 | 700 | // const auth = new OAuth2PopupFlow(options); 701 | // window.location.hash = 'shouldn\t match'; 702 | 703 | // const result = auth.handleRedirect(); 704 | // expect(result).toBe('NO_HASH_MATCH'); 705 | // }); 706 | it('calls `afterResponse` with the `decodeUriToObject`', () => { 707 | const storage = createTestStorage(); 708 | 709 | let afterResponseCalled = false; 710 | 711 | const objectToEncode = { 712 | access_token: 'fake access token', 713 | one: 'something', 714 | two: 'something else', 715 | }; 716 | 717 | const options = { 718 | authorizationUri: 'http://example.com/oauth/authorize', 719 | clientId: 'some_test_client', 720 | redirectUri: '', 721 | scope: 'openid profile', 722 | storage, 723 | afterResponse: (obj: { [key: string]: string | undefined }) => { 724 | expect(obj).toEqual(objectToEncode); 725 | afterResponseCalled = true; 726 | }, 727 | }; 728 | 729 | const auth = new OAuth2PopupFlow(options); 730 | window.location.hash = `#${OAuth2PopupFlow.encodeObjectToUri( 731 | objectToEncode, 732 | )}`; 733 | 734 | const result = auth.handleRedirect(); 735 | expect(result).toBe('SUCCESS'); 736 | expect(afterResponseCalled).toBe(true); 737 | }); 738 | it('returns early with `false` if `rawToken` is falsy', () => { 739 | const storage = createTestStorage(); 740 | 741 | const options = { 742 | authorizationUri: 'http://example.com/oauth/authorize', 743 | clientId: 'some_test_client', 744 | redirectUri: '', 745 | scope: 'openid profile', 746 | storage, 747 | }; 748 | 749 | const auth = new OAuth2PopupFlow(options); 750 | window.location.hash = `#${OAuth2PopupFlow.encodeObjectToUri({ 751 | access_token: '', 752 | })}`; 753 | 754 | const result = auth.handleRedirect(); 755 | expect(result).toBe('FALSY_TOKEN'); 756 | }); 757 | it('returns `SUCCESS` setting the `_rawToken` and clearing the hash if token is valid', () => { 758 | const storage = createTestStorage(); 759 | 760 | const options = { 761 | authorizationUri: 'http://example.com/oauth/authorize', 762 | clientId: 'some_test_client', 763 | redirectUri: '', 764 | scope: 'openid profile', 765 | storage, 766 | }; 767 | 768 | window.location.hash = `#${OAuth2PopupFlow.encodeObjectToUri({ 769 | access_token: 'some token thing', 770 | })}`; 771 | 772 | const auth = new OAuth2PopupFlow(options); 773 | 774 | const result = auth.handleRedirect(); 775 | expect(result).toBe('SUCCESS'); 776 | expect(storage.getItem('token')).toBe('some token thing'); 777 | expect(window.location.hash).toBe(''); 778 | }); 779 | }); 780 | 781 | describe('tryLoginPopup', () => { 782 | it('returns `ALREADY_LOGGED_IN` if already `loggedIn()`', async () => { 783 | const storage = createTestStorage(); 784 | const examplePayload = { 785 | foo: 'something', 786 | bar: 5, 787 | exp: Math.floor(new Date().getTime() / 1000) + 1000, 788 | }; 789 | const exampleToken = [ 790 | 'blah blah header', 791 | window.btoa(JSON.stringify(examplePayload)), 792 | 'this is the signature section', 793 | ].join('.'); 794 | storage._storage.token = exampleToken; 795 | 796 | const options = { 797 | authorizationUri: 'http://example.com/oauth/authorize', 798 | clientId: 'some_test_client', 799 | redirectUri: '', 800 | scope: 'openid profile', 801 | storage, 802 | }; 803 | 804 | const auth = new OAuth2PopupFlow(options); 805 | 806 | expect(auth.loggedIn()).toBe(true); 807 | expect(await auth.tryLoginPopup()).toBe('ALREADY_LOGGED_IN'); 808 | }); 809 | it("doesn't call `beforePopup` if it doesn't exist", async () => { 810 | const storage = createTestStorage(); 811 | 812 | (window as any).open = () => undefined; 813 | 814 | const options = { 815 | authorizationUri: 'http://example.com/oauth/authorize', 816 | clientId: 'some_test_client', 817 | redirectUri: '', 818 | scope: 'openid profile', 819 | storage, 820 | }; 821 | 822 | const auth = new OAuth2PopupFlow(options); 823 | 824 | expect(auth.loggedIn()).toBe(false); 825 | expect(await auth.tryLoginPopup()).toBe('POPUP_FAILED'); 826 | }); 827 | it('calls `beforePopup` synchronously', async () => { 828 | const storage = createTestStorage(); 829 | 830 | (window as any).open = () => undefined; 831 | 832 | let beforePopupCalled = false; 833 | 834 | const options = { 835 | authorizationUri: 'http://example.com/oauth/authorize', 836 | clientId: 'some_test_client', 837 | redirectUri: '', 838 | scope: 'openid profile', 839 | storage, 840 | beforePopup: () => { 841 | beforePopupCalled = true; 842 | }, 843 | }; 844 | 845 | const auth = new OAuth2PopupFlow(options); 846 | 847 | expect(auth.loggedIn()).toBe(false); 848 | expect(await auth.tryLoginPopup()).toBe('POPUP_FAILED'); 849 | expect(beforePopupCalled).toBe(true); 850 | }); 851 | it('calls `beforePopup` asynchronously', async () => { 852 | const storage = createTestStorage(); 853 | 854 | (window as any).open = () => undefined; 855 | 856 | let beforePopupCalled = false; 857 | 858 | const options = { 859 | authorizationUri: 'http://example.com/oauth/authorize', 860 | clientId: 'some_test_client', 861 | redirectUri: '', 862 | scope: 'openid profile', 863 | storage, 864 | beforePopup: async () => { 865 | expect(await OAuth2PopupFlow.time(0)).toBe('TIMER'); 866 | beforePopupCalled = true; 867 | }, 868 | }; 869 | 870 | const auth = new OAuth2PopupFlow(options); 871 | 872 | expect(auth.loggedIn()).toBe(false); 873 | expect(await auth.tryLoginPopup()).toBe('POPUP_FAILED'); 874 | expect(beforePopupCalled).toBe(true); 875 | }); 876 | it('calls `additionalAuthorizationParameters` if it is a function', async () => { 877 | const storage = createTestStorage(); 878 | let openCalled = false; 879 | 880 | (window as any).open = (url: string) => { 881 | expect(url.includes('foo=bar')).toBe(true); 882 | openCalled = true; 883 | }; 884 | 885 | const options = { 886 | authorizationUri: 'http://example.com/oauth/authorize', 887 | clientId: 'some_test_client', 888 | redirectUri: '', 889 | scope: 'openid profile', 890 | storage, 891 | additionalAuthorizationParameters: () => { 892 | return { 893 | foo: 'bar', 894 | }; 895 | }, 896 | }; 897 | 898 | const auth = new OAuth2PopupFlow(options); 899 | 900 | expect(await auth.tryLoginPopup()).toBe('POPUP_FAILED'); 901 | expect(openCalled).toBe(true); 902 | }); 903 | it('uses `additionalAuthorizationParameters` if it is an object', async () => { 904 | const storage = createTestStorage(); 905 | let openCalled = false; 906 | 907 | (window as any).open = (url: string) => { 908 | expect(url.includes('foo=bar')).toBe(true); 909 | openCalled = true; 910 | }; 911 | 912 | const options = { 913 | authorizationUri: 'http://example.com/oauth/authorize', 914 | clientId: 'some_test_client', 915 | redirectUri: '', 916 | scope: 'openid profile', 917 | storage, 918 | additionalAuthorizationParameters: { foo: 'bar' }, 919 | }; 920 | 921 | const auth = new OAuth2PopupFlow(options); 922 | 923 | expect(await auth.tryLoginPopup()).toBe('POPUP_FAILED'); 924 | expect(openCalled).toBe(true); 925 | }); 926 | it('returns `SUCCESS` and calls `close` on the popup and fires and event', async () => { 927 | const storage = createTestStorage(); 928 | 929 | let closedCalled = false; 930 | let resolve!: () => void; 931 | const eventCalled = new Promise((thisResolve) => (resolve = thisResolve)); 932 | 933 | (window as any).open = () => ({ 934 | close: () => { 935 | closedCalled = true; 936 | }, 937 | }); 938 | 939 | const options = { 940 | authorizationUri: 'http://example.com/oauth/authorize', 941 | clientId: 'some_test_client', 942 | redirectUri: '', 943 | scope: 'openid profile', 944 | storage, 945 | }; 946 | 947 | const examplePayload = { 948 | foo: 'something', 949 | bar: 5, 950 | exp: Math.floor(new Date().getTime() / 1000) + 1000, 951 | }; 952 | const exampleToken = [ 953 | 'blah blah header', 954 | window.btoa(JSON.stringify(examplePayload)), 955 | 'this is the signature section', 956 | ].join('.'); 957 | 958 | const auth = new OAuth2PopupFlow(options); 959 | auth.addEventListener('login', resolve); 960 | OAuth2PopupFlow.time(0).then(() => { 961 | storage._storage.token = exampleToken; 962 | }); 963 | 964 | expect(auth.loggedIn()).toBe(false); 965 | expect(await auth.tryLoginPopup()).toBe('SUCCESS'); 966 | expect(closedCalled).toBe(true); 967 | await eventCalled; 968 | }); 969 | }); 970 | 971 | describe('authenticated', () => { 972 | it('only resolves after a `loggedIn()` is truthy', async () => { 973 | const storage = createTestStorage(); 974 | 975 | const options = { 976 | authorizationUri: 'http://example.com/oauth/authorize', 977 | clientId: 'some_test_client', 978 | redirectUri: '', 979 | scope: 'openid profile', 980 | storage, 981 | }; 982 | 983 | const examplePayload = { 984 | foo: 'something', 985 | bar: 5, 986 | exp: Math.floor(new Date().getTime() / 1000) + 1000, 987 | }; 988 | const exampleToken = [ 989 | 'blah blah header', 990 | window.btoa(JSON.stringify(examplePayload)), 991 | 'this is the signature section', 992 | ].join('.'); 993 | 994 | const auth = new OAuth2PopupFlow(options); 995 | OAuth2PopupFlow.time(10).then(() => { 996 | storage._storage.token = exampleToken; 997 | }); 998 | 999 | expect(auth.loggedIn()).toBe(false); 1000 | // this won't resolve and the test will fail unless `loggedIn` is truthy 1001 | await auth.authenticated(); 1002 | }); 1003 | }); 1004 | 1005 | describe('token', () => { 1006 | it('returns the `_rawToken` if `loggedIn()`', async () => { 1007 | const storage = createTestStorage(); 1008 | 1009 | const options = { 1010 | authorizationUri: 'http://example.com/oauth/authorize', 1011 | clientId: 'some_test_client', 1012 | redirectUri: '', 1013 | scope: 'openid profile', 1014 | storage, 1015 | }; 1016 | 1017 | const auth = new OAuth2PopupFlow(options); 1018 | 1019 | const examplePayload = { 1020 | foo: 'something', 1021 | bar: 5, 1022 | exp: Math.floor(new Date().getTime() / 1000) + 1000, 1023 | }; 1024 | const exampleToken = [ 1025 | 'blah blah header', 1026 | window.btoa(JSON.stringify(examplePayload)), 1027 | 'this is the signature section', 1028 | ].join('.'); 1029 | 1030 | storage._storage.token = exampleToken; 1031 | 1032 | const token = await auth.token(); 1033 | 1034 | expect(token).toEqual(exampleToken); 1035 | }); 1036 | it('throws if `_rawToken` was falsy after being authenticated', async () => { 1037 | const storage = createTestStorage(); 1038 | 1039 | const options = { 1040 | authorizationUri: 'http://example.com/oauth/authorize', 1041 | clientId: 'some_test_client', 1042 | redirectUri: '', 1043 | scope: 'openid profile', 1044 | storage, 1045 | }; 1046 | 1047 | const auth = new OAuth2PopupFlow(options); 1048 | 1049 | expect(auth.loggedIn()).toBe(false); 1050 | spyOn(auth, 'authenticated'); 1051 | 1052 | let catchCalled = false; 1053 | 1054 | try { 1055 | await auth.token(); 1056 | } catch (e) { 1057 | expect(e.message).toBe('Token was falsy after being authenticated.'); 1058 | catchCalled = true; 1059 | } finally { 1060 | expect(catchCalled).toBe(true); 1061 | } 1062 | }); 1063 | }); 1064 | 1065 | describe('tokenPayload', () => { 1066 | it('returns the `_rawToken` if `loggedIn()`', async () => { 1067 | const storage = createTestStorage(); 1068 | 1069 | const options = { 1070 | authorizationUri: 'http://example.com/oauth/authorize', 1071 | clientId: 'some_test_client', 1072 | redirectUri: '', 1073 | scope: 'openid profile', 1074 | storage, 1075 | }; 1076 | 1077 | const auth = new OAuth2PopupFlow(options); 1078 | 1079 | const examplePayload = { 1080 | foo: 'something', 1081 | bar: 5, 1082 | exp: Math.floor(new Date().getTime() / 1000) + 1000, 1083 | }; 1084 | const exampleToken = [ 1085 | 'blah blah header', 1086 | window.btoa(JSON.stringify(examplePayload)), 1087 | 'this is the signature section', 1088 | ].join('.'); 1089 | 1090 | storage._storage.token = exampleToken; 1091 | 1092 | const payload = await auth.tokenPayload(); 1093 | 1094 | expect(payload).toEqual(examplePayload); 1095 | }); 1096 | it('throws if `_rawToken` was falsy after being authenticated', async () => { 1097 | const storage = createTestStorage(); 1098 | 1099 | const options = { 1100 | authorizationUri: 'http://example.com/oauth/authorize', 1101 | clientId: 'some_test_client', 1102 | redirectUri: '', 1103 | scope: 'openid profile', 1104 | storage, 1105 | }; 1106 | 1107 | const auth = new OAuth2PopupFlow(options); 1108 | 1109 | expect(auth.loggedIn()).toBe(false); 1110 | spyOn(auth, 'authenticated'); 1111 | 1112 | let catchCalled = false; 1113 | 1114 | try { 1115 | await auth.tokenPayload(); 1116 | } catch (e) { 1117 | expect(e.message).toBe( 1118 | 'Token payload was falsy after being authenticated.', 1119 | ); 1120 | catchCalled = true; 1121 | } finally { 1122 | expect(catchCalled).toBe(true); 1123 | } 1124 | }); 1125 | }); 1126 | 1127 | describe('EventTarget', () => { 1128 | it('allows events to be listened to and dispatched', () => { 1129 | const storage = createTestStorage(); 1130 | 1131 | const options = { 1132 | authorizationUri: 'http://example.com/oauth/authorize', 1133 | clientId: 'some_test_client', 1134 | redirectUri: '', 1135 | scope: 'openid profile', 1136 | storage, 1137 | }; 1138 | 1139 | const handler = jest.fn(); 1140 | 1141 | const auth = new OAuth2PopupFlow(options); 1142 | auth.addEventListener('login', handler); 1143 | auth.dispatchEvent(new Event('login')); 1144 | auth.dispatchEvent(new Event('login')); 1145 | auth.dispatchEvent(new Event('login')); 1146 | 1147 | expect(handler).toBeCalledTimes(3); 1148 | }); 1149 | 1150 | it('allows event listeners to be removed', () => { 1151 | const storage = createTestStorage(); 1152 | const handler = jest.fn(); 1153 | 1154 | const options = { 1155 | authorizationUri: 'http://example.com/oauth/authorize', 1156 | clientId: 'some_test_client', 1157 | redirectUri: '', 1158 | scope: 'openid profile', 1159 | storage, 1160 | }; 1161 | 1162 | const auth = new OAuth2PopupFlow(options); 1163 | auth.addEventListener('login', handler); 1164 | auth.dispatchEvent(new Event('login')); 1165 | auth.dispatchEvent(new Event('login')); 1166 | auth.dispatchEvent(new Event('login')); 1167 | 1168 | auth.removeEventListener('login', handler); 1169 | auth.dispatchEvent(new Event('login')); 1170 | 1171 | expect(handler).toBeCalledTimes(3); 1172 | }); 1173 | 1174 | it("doesn't throw when the type of event doesn't exist", () => { 1175 | const storage = createTestStorage(); 1176 | 1177 | const options = { 1178 | authorizationUri: 'http://example.com/oauth/authorize', 1179 | clientId: 'some_test_client', 1180 | redirectUri: '', 1181 | scope: 'openid profile', 1182 | storage, 1183 | }; 1184 | 1185 | const auth = new OAuth2PopupFlow(options); 1186 | auth.removeEventListener('login', () => {}); 1187 | }); 1188 | 1189 | it('allows for an event handler object', () => { 1190 | const storage = createTestStorage(); 1191 | 1192 | const options = { 1193 | authorizationUri: 'http://example.com/oauth/authorize', 1194 | clientId: 'some_test_client', 1195 | redirectUri: '', 1196 | scope: 'openid profile', 1197 | storage, 1198 | }; 1199 | 1200 | const auth = new OAuth2PopupFlow(options); 1201 | const handler = jest.fn(); 1202 | 1203 | const eventListenerObject = { 1204 | handleEvent: handler, 1205 | }; 1206 | 1207 | auth.addEventListener('login', eventListenerObject); 1208 | auth.dispatchEvent(new Event('login')); 1209 | auth.dispatchEvent(new Event('login')); 1210 | auth.dispatchEvent(new Event('login')); 1211 | 1212 | expect(handler).toBeCalledTimes(3); 1213 | }); 1214 | 1215 | it('defaults to a no-op when there is nothing callable', () => { 1216 | const storage = createTestStorage(); 1217 | 1218 | const options = { 1219 | authorizationUri: 'http://example.com/oauth/authorize', 1220 | clientId: 'some_test_client', 1221 | redirectUri: '', 1222 | scope: 'openid profile', 1223 | storage, 1224 | }; 1225 | 1226 | const auth = new OAuth2PopupFlow(options); 1227 | const handler = jest.fn(); 1228 | 1229 | const notRealObj = {}; 1230 | 1231 | auth.addEventListener('login', notRealObj as any); 1232 | auth.dispatchEvent(new Event('login')); 1233 | auth.dispatchEvent(new Event('login')); 1234 | auth.dispatchEvent(new Event('login')); 1235 | 1236 | expect(handler).toBeCalledTimes(0); 1237 | }); 1238 | }); 1239 | }); 1240 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * The type of the configuration object used to create a `OAuth2PopupFlow` 3 | * 4 | * Each property has a JSDOC description to explain what it does. 5 | */ 6 | export interface OAuth2PopupFlowOptions { 7 | /** 8 | * REQUIRED 9 | * The full URI of the authorization endpoint provided by the authorization server. 10 | * 11 | * e.g. `https://example.com/oauth/authorize` 12 | */ 13 | authorizationUri: string; 14 | /** 15 | * REQUIRED 16 | * The client ID of your application provided by the authorization server. 17 | * 18 | * This client ID is sent to the authorization server using `authorizationUrl` endpoint in the 19 | * query portion of the URL along with the other parameters. 20 | * This value will be URL encoded like so: 21 | * 22 | * `https://example.com/oauth/authorize?client_id=SOME_CLIENT_ID_VALUE...` 23 | */ 24 | clientId: string; 25 | /** 26 | * REQUIRED 27 | * The URI that the authorization server will to redirect after the user has been authenticated. 28 | * This redirect URI *must* be a URI from *your application* and it must also be registered with 29 | * the authorization server. Some authorities call this a "callback URLs" or "login URLs" etc. 30 | * 31 | * > e.g. `http://localhost:4200/redirect` for local testing 32 | * > 33 | * > or `https://my-application.com/redirect` for prod 34 | * 35 | * This redirect URI is sent to the authorization server using `authorizationUrl` endpoint in the 36 | * query portion of the URL along with the other parameters. 37 | * This value will be URL encoded like so: 38 | * 39 | * `https://example.com/oauth/authorize?redirect_URI=http%3A%2F%2Flocalhost%2Fredirect...` 40 | */ 41 | redirectUri: string; 42 | /** 43 | * REQUIRED 44 | * A list permission separated by spaces that is the scope of permissions your application is 45 | * requesting from the authorization server. If the user is logging in the first time, it may ask 46 | * them to approve those permission before authorizing your application. 47 | * 48 | * > e.g. `openid profile` 49 | * 50 | * The scopes are sent to the authorization server using `authorizationUrl` endpoint in the 51 | * query portion of the URL along with the other parameters. 52 | * This value will be URL encoded like so: 53 | * 54 | * `https://example.com/oauth/authorize?scope=openid%20profile...` 55 | */ 56 | scope: string; 57 | /** 58 | * OPTIONAL 59 | * `response_type` is an argument to be passed to the authorization server via the 60 | * `authorizationUri` endpoint in the query portion of the URL. 61 | * 62 | * Most implementations of oauth2 use the default value of `token` to tell the authorization 63 | * server to start the implicit grant flow but you may override that value with this option. 64 | * 65 | * For example, Auth0--an OAuth2 authority/authorization server--requires the value `id_token` 66 | * instead of `token` for the implicit flow. 67 | * 68 | * The response type is sent to the authorization server using `authorizationUrl` endpoint in the 69 | * query portion of the URL along with the other parameters. 70 | * This value will be URL encoded like so: 71 | * 72 | * `https://example.com/oauth/authorize?response_type=token...` 73 | */ 74 | responseType?: string; 75 | /** 76 | * OPTIONAL 77 | * The key used to save the token in the given storage. The default key is `token` so the token 78 | * would be persisted in `localStorage.getItem('token')` if `localStorage` was the configured 79 | * `Storage`. 80 | */ 81 | accessTokenStorageKey?: string; 82 | /** 83 | * OPTIONAL 84 | * During `handleRedirect`, the method will try to parse `window.location.hash` to an object using 85 | * `OAuth2PopupFlow.decodeUriToObject`. After that object has been decoded, this property 86 | * determines the key to use that will retrieve the token from that object. 87 | * 88 | * By default it is `access_token` but you you may need to change that e.g. Auth0 uses `id_token`. 89 | */ 90 | accessTokenResponseKey?: string; 91 | /** 92 | * OPTIONAL 93 | * The storage implementation of choice. It can be `localStorage` or `sessionStorage` or something 94 | * else. By default, this is `localStorage` and `localStorage` is the preferred `Storage`. 95 | */ 96 | storage?: Storage; 97 | /** 98 | * OPTIONAL 99 | * The `authenticated` method periodically checks `loggedIn()` and resolves when `loggedIn()` 100 | * returns `true`. 101 | * 102 | * This property is how long it will wait between checks. By default it is `200`. 103 | */ 104 | pollingTime?: number; 105 | /** 106 | * OPTIONAL 107 | * Some oauth authorities require additional parameters to be passed to the `authorizationUri` 108 | * URL in order for the implicit grant flow to work. 109 | * 110 | * For example: [Auth0--an OAuth2 authority/authorization server--requires the parameters 111 | * `nonce`][0] 112 | * be passed along with every call to the `authorizationUri`. You can do that like so: 113 | * 114 | * ```ts 115 | * const auth = new OAuth2PopupFlow({ 116 | * authorizationUri: 'https://example.com/oauth/authorize', 117 | * clientId: 'foo_client', 118 | * redirectUri: 'http://localhost:8080/redirect', 119 | * scope: 'openid profile', 120 | * // this can be a function or static object 121 | * additionalAuthorizationParameters: () => { 122 | * // in prod, consider something more cryptographic 123 | * const nonce = Math.floor(Math.random() * 1000).toString(); 124 | * localStorage.setItem('nonce', nonce); 125 | * return { nonce }; 126 | * // `nonce` will now be encoded in the URL like so: 127 | * // https://example.com/oauth/authorize?client_id=foo_client...nonce=1234 128 | * }, 129 | * // the token returned by Auth0, has the `nonce` in the payload 130 | * // you can add this additional check now 131 | * tokenValidator: ({ payload }) => { 132 | * const storageNonce = parseInt(localStorage.getItem('nonce'), 10); 133 | * const payloadNonce = parseInt(payload.nonce, 10); 134 | * return storageNonce === payloadNonce; 135 | * }, 136 | * }); 137 | * ``` 138 | * 139 | * [0]: https://auth0.com/docs/api-auth/tutorials/nonce#generate-a-cryptographically-random-nonce 140 | */ 141 | additionalAuthorizationParameters?: 142 | | (() => { [key: string]: string }) 143 | | { [key: string]: string }; 144 | /** 145 | * OPTIONAL 146 | * This function intercepts the `loggedIn` method and causes it to return early with `false` if 147 | * this function itself returns `false`. Use this function to validate claims in the token payload 148 | * or token. 149 | * 150 | * [For example: validating the `nonce`:][0] 151 | * 152 | * ```ts 153 | * const auth = new OAuth2PopupFlow({ 154 | * authorizationUri: 'https://example.com/oauth/authorize', 155 | * clientId: 'foo_client', 156 | * redirectUri: 'http://localhost:8080/redirect', 157 | * scope: 'openid profile', 158 | * // this can be a function or static object 159 | * additionalAuthorizationParameters: () => { 160 | * // in prod, consider something more cryptographic 161 | * const nonce = Math.floor(Math.random() * 1000).toString(); 162 | * localStorage.setItem('nonce', nonce); 163 | * return { nonce }; 164 | * // `nonce` will now be encoded in the URL like so: 165 | * // https://example.com/oauth/authorize?client_id=foo_client...nonce=1234 166 | * }, 167 | * // the token returned by Auth0, has the `nonce` in the payload 168 | * // you can add this additional check now 169 | * tokenValidator: ({ payload }) => { 170 | * const storageNonce = parseInt(localStorage.getItem('nonce'), 10); 171 | * const payloadNonce = parseInt(payload.nonce, 10); 172 | * return storageNonce === payloadNonce; 173 | * }, 174 | * }); 175 | * ``` 176 | * 177 | * [0]: https://auth0.com/docs/api-auth/tutorials/nonce#generate-a-cryptographically-random-nonce 178 | */ 179 | tokenValidator?: (options: { 180 | payload: TokenPayload; 181 | token: string; 182 | }) => boolean; 183 | /** 184 | * OPTIONAL 185 | * A hook that runs in `tryLoginPopup` before any popup is opened. This function can return a 186 | * `Promise` and the popup will not open until it resolves. 187 | * 188 | * A typical use case would be to wait a certain amount of time before opening the popup to let 189 | * the user see why the popup is happening. 190 | */ 191 | beforePopup?: () => any | Promise; 192 | /** 193 | * OPTIONAL 194 | * A hook that runs in `handleRedirect` that takes in the result of the hash payload from the 195 | * authorization server. Use this hook to grab more from the response or to debug the response 196 | * from the authorization URL. 197 | */ 198 | afterResponse?: (authorizationResponse: { 199 | [key: string]: string | undefined; 200 | }) => void; 201 | } 202 | 203 | export class OAuth2PopupFlow 204 | implements EventTarget { 205 | authorizationUri: string; 206 | clientId: string; 207 | redirectUri: string; 208 | scope: string; 209 | responseType: string; 210 | accessTokenStorageKey: string; 211 | accessTokenResponseKey: string; 212 | storage: Storage; 213 | pollingTime: number; 214 | additionalAuthorizationParameters?: 215 | | (() => { [key: string]: string }) 216 | | { [key: string]: string }; 217 | tokenValidator?: (options: { 218 | payload: TokenPayload; 219 | token: string; 220 | }) => boolean; 221 | beforePopup?: () => any | Promise; 222 | afterResponse?: (authorizationResponse: { 223 | [key: string]: string | undefined; 224 | }) => void; 225 | private _eventListeners: { 226 | [type: string]: EventListenerOrEventListenerObject[]; 227 | }; 228 | 229 | constructor(options: OAuth2PopupFlowOptions) { 230 | this.authorizationUri = options.authorizationUri; 231 | this.clientId = options.clientId; 232 | this.redirectUri = options.redirectUri; 233 | this.scope = options.scope; 234 | this.responseType = options.responseType || 'token'; 235 | this.accessTokenStorageKey = options.accessTokenStorageKey || 'token'; 236 | this.accessTokenResponseKey = 237 | options.accessTokenResponseKey || 'access_token'; 238 | this.storage = options.storage || window.localStorage; 239 | this.pollingTime = options.pollingTime || 200; 240 | this.additionalAuthorizationParameters = 241 | options.additionalAuthorizationParameters; 242 | this.tokenValidator = options.tokenValidator; 243 | this.beforePopup = options.beforePopup; 244 | this.afterResponse = options.afterResponse; 245 | this._eventListeners = {}; 246 | } 247 | 248 | private get _rawToken() { 249 | return this.storage.getItem(this.accessTokenStorageKey) || undefined; 250 | } 251 | private set _rawToken(value: string | undefined) { 252 | if (value === null) return; 253 | if (value === undefined) return; 254 | 255 | this.storage.setItem(this.accessTokenStorageKey, value); 256 | } 257 | 258 | private get _rawTokenPayload() { 259 | const rawToken = this._rawToken; 260 | if (!rawToken) return undefined; 261 | 262 | const tokenSplit = rawToken.split('.'); 263 | const encodedPayload = tokenSplit[1]; 264 | if (!encodedPayload) return undefined; 265 | 266 | const decodedPayloadJson = window.atob( 267 | encodedPayload.replace('-', '+').replace('_', '/'), 268 | ); 269 | const decodedPayload = OAuth2PopupFlow.jsonParseOrUndefined( 270 | decodedPayloadJson, 271 | ); 272 | return decodedPayload; 273 | } 274 | 275 | /** 276 | * A simple synchronous method that returns whether or not the user is logged in by checking 277 | * whether or not their token is present and not expired. 278 | */ 279 | loggedIn() { 280 | const decodedPayload = this._rawTokenPayload; 281 | if (!decodedPayload) return false; 282 | 283 | if (this.tokenValidator) { 284 | const token = this._rawToken!; 285 | if (!this.tokenValidator({ payload: decodedPayload, token })) 286 | return false; 287 | } 288 | 289 | const exp = decodedPayload.exp; 290 | if (!exp) return false; 291 | 292 | if (new Date().getTime() > exp * 1000) return false; 293 | return true; 294 | } 295 | 296 | /** 297 | * Returns true only if there is a token in storage and that token is expired. Use this to method 298 | * in conjunction with `loggedIn` to display a message like "you need to *re*login" vs "you need 299 | * to login". 300 | */ 301 | tokenExpired() { 302 | const decodedPayload = this._rawTokenPayload; 303 | if (!decodedPayload) return false; 304 | 305 | const exp = decodedPayload.exp; 306 | if (!exp) return false; 307 | 308 | if (new Date().getTime() <= exp * 1000) return false; 309 | 310 | return true; 311 | } 312 | 313 | /** 314 | * Deletes the token from the given storage causing `loggedIn` to return false on its next call. 315 | * Also dispatches `logout` event 316 | */ 317 | logout() { 318 | this.storage.removeItem(this.accessTokenStorageKey); 319 | this.dispatchEvent(new Event('logout')); 320 | } 321 | 322 | /** 323 | * Call this method in a route of the `redirectUri`. This method takes the value of the hash at 324 | * `window.location.hash` and attempts to grab the token from the URL. 325 | * 326 | * If the method was able to grab the token, it will return `'SUCCESS'` else it will return a 327 | * different string. 328 | */ 329 | handleRedirect() { 330 | const locationHref = window.location.href; 331 | if (!locationHref.startsWith(this.redirectUri)) 332 | return 'REDIRECT_URI_MISMATCH'; 333 | 334 | const rawHash = window.location.hash; 335 | if (!rawHash) return 'FALSY_HASH'; 336 | const hashMatch = /#(.*)/.exec(rawHash); 337 | 338 | // this case won't happen because the browser typically adds the `#` always 339 | if (!hashMatch) return 'NO_HASH_MATCH'; 340 | const hash = hashMatch[1]; 341 | 342 | const authorizationResponse = OAuth2PopupFlow.decodeUriToObject(hash); 343 | if (this.afterResponse) { 344 | this.afterResponse(authorizationResponse); 345 | } 346 | const rawToken = authorizationResponse[this.accessTokenResponseKey]; 347 | if (!rawToken) return 'FALSY_TOKEN'; 348 | 349 | this._rawToken = rawToken; 350 | window.location.hash = ''; 351 | return 'SUCCESS'; 352 | } 353 | 354 | /** 355 | * supported events are: 356 | * 357 | * 1. `logout`–fired when the `logout()` method is called and 358 | * 2. `login`–fired during the `tryLoginPopup()` method is called and succeeds 359 | */ 360 | addEventListener(type: string, listener: EventListenerOrEventListenerObject) { 361 | const listeners = this._eventListeners[type] || []; 362 | listeners.push(listener); 363 | this._eventListeners[type] = listeners; 364 | } 365 | 366 | /** 367 | * Use this to dispatch an event to the internal `EventTarget` 368 | */ 369 | dispatchEvent(event: Event) { 370 | const listeners = this._eventListeners[event.type] || []; 371 | for (const listener of listeners) { 372 | const dispatch = 373 | typeof listener === 'function' 374 | ? listener 375 | : typeof listener === 'object' && 376 | typeof listener.handleEvent === 'function' 377 | ? listener.handleEvent.bind(listener) 378 | : () => {}; 379 | 380 | dispatch(event); 381 | } 382 | return true; 383 | } 384 | 385 | /** 386 | * Removes the event listener in target's event listener list with the same type, callback, and options. 387 | */ 388 | removeEventListener( 389 | type: string, 390 | listener: EventListenerOrEventListenerObject, 391 | ) { 392 | const listeners = this._eventListeners[type] || []; 393 | this._eventListeners[type] = listeners.filter((l) => l !== listener); 394 | } 395 | 396 | /** 397 | * Tries to open a popup to login the user in. If the user is already `loggedIn()` it will 398 | * immediately return `'ALREADY_LOGGED_IN'`. If the popup fails to open, it will immediately 399 | * return `'POPUP_FAILED'` else it will wait for `loggedIn()` to be `true` and eventually 400 | * return `'SUCCESS'`. 401 | * 402 | * Also dispatches `login` event 403 | */ 404 | async tryLoginPopup() { 405 | if (this.loggedIn()) return 'ALREADY_LOGGED_IN'; 406 | 407 | if (this.beforePopup) { 408 | await Promise.resolve(this.beforePopup()); 409 | } 410 | 411 | const additionalParams = 412 | typeof this.additionalAuthorizationParameters === 'function' 413 | ? this.additionalAuthorizationParameters() 414 | : typeof this.additionalAuthorizationParameters === 'object' 415 | ? this.additionalAuthorizationParameters 416 | : {}; 417 | 418 | const popup = window.open( 419 | `${this.authorizationUri}?${OAuth2PopupFlow.encodeObjectToUri({ 420 | client_id: this.clientId, 421 | response_type: this.responseType, 422 | redirect_uri: this.redirectUri, 423 | scope: this.scope, 424 | ...additionalParams, 425 | })}`, 426 | ); 427 | if (!popup) return 'POPUP_FAILED'; 428 | 429 | await this.authenticated(); 430 | popup.close(); 431 | this.dispatchEvent(new Event('login')); 432 | 433 | return 'SUCCESS'; 434 | } 435 | 436 | /** 437 | * A promise that does not resolve until `loggedIn()` is true. This uses the `pollingTime` 438 | * to wait until checking if `loggedIn()` is `true`. 439 | */ 440 | async authenticated() { 441 | while (!this.loggedIn()) { 442 | await OAuth2PopupFlow.time(this.pollingTime); 443 | } 444 | } 445 | 446 | /** 447 | * If the user is `loggedIn()`, the token will be returned immediate, else it will open a popup 448 | * and wait until the user is `loggedIn()` (i.e. a new token has been added). 449 | */ 450 | async token() { 451 | await this.authenticated(); 452 | const token = this._rawToken; 453 | if (!token) throw new Error('Token was falsy after being authenticated.'); 454 | return token; 455 | } 456 | 457 | /** 458 | * If the user is `loggedIn()`, the token payload will be returned immediate, else it will open a 459 | * popup and wait until the user is `loggedIn()` (i.e. a new token has been added). 460 | */ 461 | async tokenPayload() { 462 | await this.authenticated(); 463 | const payload = this._rawTokenPayload; 464 | if (!payload) 465 | throw new Error('Token payload was falsy after being authenticated.'); 466 | return payload; 467 | } 468 | 469 | /** 470 | * wraps `JSON.parse` and return `undefined` if the parsing failed 471 | */ 472 | static jsonParseOrUndefined(json: string) { 473 | try { 474 | return JSON.parse(json) as T; 475 | } catch (e) { 476 | return undefined; 477 | } 478 | } 479 | 480 | /** 481 | * wraps `setTimeout` in a `Promise` that resolves to `'TIMER'` 482 | */ 483 | static time(milliseconds: number) { 484 | return new Promise<'TIMER'>((resolve) => 485 | window.setTimeout(() => resolve('TIMER'), milliseconds), 486 | ); 487 | } 488 | 489 | /** 490 | * wraps `decodeURIComponent` and returns the original string if it cannot be decoded 491 | */ 492 | static decodeUri(str: string) { 493 | try { 494 | return decodeURIComponent(str); 495 | } catch { 496 | return str; 497 | } 498 | } 499 | 500 | /** 501 | * Encodes an object of strings to a URL 502 | * 503 | * `{one: 'two', buckle: 'shoes or something'}` ==> `one=two&buckle=shoes%20or%20something` 504 | */ 505 | static encodeObjectToUri(obj: { [key: string]: string }) { 506 | return Object.keys(obj) 507 | .map((key) => ({ key, value: obj[key] })) 508 | .map( 509 | ({ key, value }) => 510 | `${encodeURIComponent(key)}=${encodeURIComponent(value)}`, 511 | ) 512 | .join('&'); 513 | } 514 | 515 | /** 516 | * Decodes a URL string to an object of string 517 | * 518 | * `one=two&buckle=shoes%20or%20something` ==> `{one: 'two', buckle: 'shoes or something'}` 519 | */ 520 | static decodeUriToObject(str: string) { 521 | return str.split('&').reduce((decoded, keyValuePair) => { 522 | const [keyEncoded, valueEncoded] = keyValuePair.split('='); 523 | const key = this.decodeUri(keyEncoded); 524 | const value = this.decodeUri(valueEncoded); 525 | decoded[key] = value; 526 | return decoded; 527 | }, {} as { [key: string]: string | undefined }); 528 | } 529 | } 530 | 531 | export default OAuth2PopupFlow; 532 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "module": "commonjs", 5 | "lib": ["dom", "es2015"], 6 | "strict": true, 7 | "noUnusedLocals": true, 8 | "noUnusedParameters": true, 9 | "declaration": true, 10 | "emitDeclarationOnly": true, 11 | "outDir": "dist" 12 | }, 13 | "exclude": ["./examples/**/*", "**/*.test.ts"] 14 | } 15 | --------------------------------------------------------------------------------