├── .babelrc ├── .eslintrc ├── .gitignore ├── .prettierrc ├── .travis.yml ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── src ├── actions.js ├── index.js ├── middleware.js ├── oauth2.js ├── reducers.js └── util │ ├── popup.js │ ├── token.js │ └── token.test.js └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015"], 3 | "plugins": [ 4 | "transform-es3-member-expression-literals", 5 | "transform-es3-property-literals", 6 | "transform-object-assign" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "jest" 4 | ], 5 | "env": { 6 | "browser": true, 7 | "node": true, 8 | "es6": true, 9 | "jest/globals": true 10 | }, 11 | "extends": "eslint:recommended", 12 | "parserOptions": { 13 | "sourceType": "module" 14 | }, 15 | "rules": { 16 | "indent": [ 17 | "error", 18 | 2, { 19 | "SwitchCase": 1 20 | } 21 | ], 22 | "linebreak-style": [ 23 | "error", 24 | "unix" 25 | ], 26 | "quotes": [ 27 | "error", 28 | "single" 29 | ], 30 | "semi": [ 31 | "error", 32 | "never" 33 | ] 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /dist 2 | /lib 3 | /node_modules 4 | /npm-debug.log 5 | /coverage 6 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 80 3 | } 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "8" 4 | env: 5 | global: 6 | secure: raPghfPxc16gOWuTA9SqYYJ1OVB7TjFEjiAd0hSBV4UvMylxeTByRZWIViEtNi4EbKFDCoyA4SJw9SNt9nbNPgaPq1wWuX7ThDHWhWdXaUj+7nSn3HrQ6sN7evAOzz1Rdn21vAubUBAcAbZJTx8SAERWOvDyMvBZSQxCqfqdCO0iBPZTWak8F2/3CCiudK7AB32l6SdHuYpYB27b1BuELZfx7pg2KBnR3iGO7QAYdsmYDiXj7rfQpVvfqBumCRPrzOA/epkOfJtVI1tDWA6hoMeD5MdAmfWMiZMfDhliaTzCl/r4P7oqNEeopR8debgiaGlPu5YpehkLgIRlb6pF9Wm6iaprIYlLlOFKTi7iQOv56/QmOqS/dWa4ZCTbOC3Rz50ovqKUMylxcZ6WFZyLQfh1GzzZ/Zh2OngOI3IAfx5Dij4j9KK2dZLWjRiNLMAaLjXJx6cNBTeiawi6r38zRsZz4tf6GBZFeHHE/X1hLETovjeu0v54xTrbuP4FjCD730FbYkiRJWlEcDgZxwCHO64fiCa2m7TmYZgyvlasLulu9T6ym8QFOAdtBbpqPZzlJgj+2bbcuTXsTJ6huBFNjRcgtcz/YtEWyf5peaqWukGlRSmrZSP7McVP3qx2sAh/J/4Kr6LxSYuKqg90FhMJppB13Y66eZpSZwvAcAigbuM= 7 | script: 8 | - npm test 9 | after_success: 10 | - npm run coveralls 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Danilo Bürger 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.org/danilobuerger/redux-implicit-oauth2.svg?branch=master)](https://travis-ci.org/danilobuerger/redux-implicit-oauth2) [![Coverage Status](https://coveralls.io/repos/github/danilobuerger/redux-implicit-oauth2/badge.svg?branch=master)](https://coveralls.io/github/danilobuerger/redux-implicit-oauth2?branch=master) 2 | 3 | # redux-implicit-oauth2 4 | 5 | [OAuth 2.0 Implicit Grant Flow](https://tools.ietf.org/html/rfc6749#section-4.2) with [Redux](https://github.com/reactjs/redux). 6 | 7 | ## Example (with React) 8 | 9 | The following example displays either a login or logout button depending on the state. 10 | Set the config object according to your OAuth 2.0 server parameters. 11 | The redirect callback page should be on the same site as the rest of your app. 12 | 13 | ```jsx 14 | import React from 'react' 15 | import PropTypes from 'prop-types' 16 | import { connect } from 'react-redux' 17 | import { login, logout } from 'redux-implicit-oauth2' 18 | 19 | const config = { 20 | url: "https://example.com/authorize", 21 | client: "some_client_id", 22 | redirect: "https://example.com/callback.html", 23 | scope: "some_scope", 24 | width: 400, // Width (in pixels) of login popup window. Optional, default: 400 25 | height: 400 // Height (in pixels) of login popup window. Optional, default: 400 26 | } 27 | 28 | const Login = ({ isLoggedIn, login, logout }) => { 29 | if (isLoggedIn) { 30 | return 31 | } else { 32 | return 33 | } 34 | } 35 | 36 | Login.propTypes = { 37 | isLoggedIn: PropTypes.bool.isRequired, 38 | login: PropTypes.func.isRequired, 39 | logout: PropTypes.func.isRequired 40 | } 41 | 42 | const mapStateToProps = ({ auth }) => ({ 43 | isLoggedIn: auth.isLoggedIn 44 | }) 45 | 46 | const mapDispatchToProps = { 47 | login: () => login(config), 48 | logout 49 | } 50 | 51 | export default connect(mapStateToProps, mapDispatchToProps)(Login) 52 | ``` 53 | 54 | Don't forget to add the reducer and middleware to your Redux store: 55 | 56 | ```js 57 | import { createStore, combineReducers, applyMiddleware } from 'redux' 58 | import { authMiddleware, authReducer as auth } from 'redux-implicit-oauth2' 59 | 60 | const configureStore = (initialState) => 61 | createStore( 62 | combineReducers({ 63 | // other reducers 64 | auth 65 | }), 66 | initialState, 67 | applyMiddleware( 68 | // other middleware 69 | authMiddleware 70 | ) 71 | ) 72 | 73 | export default configureStore 74 | ``` 75 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redux-implicit-oauth2", 3 | "version": "1.2.1", 4 | "description": "OAuth 2.0 Implicit Grant Flow with Redux", 5 | "keywords": [ 6 | "react", 7 | "redux", 8 | "implicit", 9 | "oauth2", 10 | "auth" 11 | ], 12 | "author": "Danilo Bürger (http://danilobuerger.de)", 13 | "license": "MIT", 14 | "homepage": "https://github.com/danilobuerger/redux-implicit-oauth2#readme", 15 | "repository": { 16 | "type": "git", 17 | "url": "https://github.com/danilobuerger/redux-implicit-oauth2.git" 18 | }, 19 | "bugs": { 20 | "url": "https://github.com/danilobuerger/redux-implicit-oauth2/issues" 21 | }, 22 | "main": "./lib/index.js", 23 | "files": [ 24 | "dist", 25 | "lib", 26 | "src" 27 | ], 28 | "dependencies": { 29 | "cuid": "^1.3.8", 30 | "query-string": "^5.0.0" 31 | }, 32 | "devDependencies": { 33 | "babel-cli": "^6.16.0", 34 | "babel-loader": "^7.1.2", 35 | "babel-plugin-transform-es3-member-expression-literals": "^6.8.0", 36 | "babel-plugin-transform-es3-property-literals": "^6.8.0", 37 | "babel-plugin-transform-object-assign": "^6.8.0", 38 | "babel-preset-es2015": "^6.16.0", 39 | "check-es3-syntax-cli": "^0.2.1", 40 | "coveralls": "^3.0.0", 41 | "cross-env": "^5.0.5", 42 | "eslint": "^4.7.2", 43 | "eslint-plugin-jest": "^21.2.0", 44 | "jest": "^21.2.1", 45 | "jest-localstorage-mock": "^2.0.1", 46 | "rimraf": "^2.5.4", 47 | "webpack": "^3.6.0" 48 | }, 49 | "scripts": { 50 | "clean": "rimraf lib dist", 51 | "lint": "eslint src", 52 | "build:lib": "babel src --out-dir lib --ignore '**/*.test.js'", 53 | "build:umd": "cross-env NODE_ENV=development webpack src/index.js dist/redux-implicit-oauth2.js", 54 | "build:umd:min": "cross-env NODE_ENV=production webpack src/index.js dist/redux-implicit-oauth2.min.js", 55 | "build": "npm run build:lib && npm run build:umd && npm run build:umd:min", 56 | "test": "jest --coverage", 57 | "coveralls": "cat ./coverage/lcov.info | coveralls", 58 | "prepublishOnly": "npm run clean && npm run lint && npm run build && check-es3-syntax --kill --print lib/ dist/" 59 | }, 60 | "jest": { 61 | "setupFiles": [ 62 | "jest-localstorage-mock" 63 | ], 64 | "collectCoverageFrom": [ 65 | "src/**/*.js" 66 | ] 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/actions.js: -------------------------------------------------------------------------------- 1 | export const LOGIN_REQUEST = 'LOGIN_REQUEST' 2 | export const LOGIN_SUCCESS = 'LOGIN_SUCCESS' 3 | export const LOGIN_FAILURE = 'LOGIN_FAILURE' 4 | export const LOGOUT = 'LOGOUT' 5 | 6 | export const loginSuccess = (token, expiresAt) => ({ 7 | type: LOGIN_SUCCESS, 8 | token, 9 | expiresAt 10 | }) 11 | 12 | export const loginFailure = error => ({ 13 | type: LOGIN_FAILURE, 14 | error 15 | }) 16 | 17 | export const login = config => ({ 18 | type: LOGIN_REQUEST, 19 | config 20 | }) 21 | 22 | export const logout = () => ({ 23 | type: LOGOUT 24 | }) 25 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import { 2 | LOGIN_REQUEST, 3 | LOGIN_SUCCESS, 4 | LOGIN_FAILURE, 5 | LOGOUT, 6 | login, 7 | logout 8 | } from './actions' 9 | import authMiddleware from './middleware' 10 | import authReducer from './reducers' 11 | 12 | export { 13 | LOGIN_REQUEST, 14 | LOGIN_SUCCESS, 15 | LOGIN_FAILURE, 16 | LOGOUT, 17 | login, 18 | logout, 19 | authMiddleware, 20 | authReducer 21 | } 22 | -------------------------------------------------------------------------------- /src/middleware.js: -------------------------------------------------------------------------------- 1 | import { setToken, removeToken } from './util/token' 2 | import authorize from './oauth2' 3 | import { 4 | LOGIN_REQUEST, 5 | LOGIN_SUCCESS, 6 | LOGIN_FAILURE, 7 | LOGOUT, 8 | loginSuccess, 9 | loginFailure 10 | } from './actions' 11 | 12 | const authMiddleware = store => next => action => { 13 | switch (action.type) { 14 | case LOGIN_REQUEST: 15 | return authorize(action.config).then( 16 | ({ token, expiresAt }) => 17 | store.dispatch(loginSuccess(token, expiresAt)), 18 | error => store.dispatch(loginFailure(error)) 19 | ) 20 | case LOGIN_SUCCESS: 21 | setToken(action.token, action.expiresAt) 22 | break 23 | case LOGIN_FAILURE: 24 | case LOGOUT: 25 | removeToken() 26 | break 27 | } 28 | 29 | return next(action) 30 | } 31 | 32 | export default authMiddleware 33 | -------------------------------------------------------------------------------- /src/oauth2.js: -------------------------------------------------------------------------------- 1 | import querystring from 'query-string' 2 | import cuid from 'cuid' 3 | import openPopup from './util/popup' 4 | 5 | const listenForCredentials = (popup, state, resolve, reject) => { 6 | let hash 7 | try { 8 | hash = popup.location.hash 9 | } catch (err) { 10 | if (process.env.NODE_ENV !== 'production') { 11 | /* eslint-disable no-console */ 12 | console.error(err) 13 | /* eslint-enable no-console */ 14 | } 15 | } 16 | 17 | if (hash) { 18 | popup.close() 19 | 20 | const response = querystring.parse(hash.substr(1)) 21 | if (response.state !== state) { 22 | reject('Invalid state returned.') 23 | } 24 | 25 | if (response.access_token) { 26 | const expiresIn = response.expires_in 27 | ? parseInt(response.expires_in) 28 | : NaN 29 | const result = { 30 | token: response.access_token, 31 | expiresAt: !isNaN(expiresIn) ? Date.now() + expiresIn * 1000 : null 32 | } 33 | resolve(result) 34 | } else { 35 | reject(response.error || 'Unknown error.') 36 | } 37 | } else if (popup.closed) { 38 | reject('Authentication was cancelled.') 39 | } else { 40 | setTimeout(() => listenForCredentials(popup, state, resolve, reject), 100) 41 | } 42 | } 43 | 44 | const authorize = config => { 45 | const state = cuid() 46 | const query = querystring.stringify({ 47 | state, 48 | response_type: 'token', 49 | client_id: config.client, 50 | scope: config.scope, 51 | redirect_uri: config.redirect 52 | }) 53 | const url = config.url + (config.url.indexOf('?') === -1 ? '?' : '&') + query 54 | const width = config.width || 400 55 | const height = config.height || 400 56 | const popup = openPopup(url, 'oauth2', width, height) 57 | 58 | return new Promise((resolve, reject) => 59 | listenForCredentials(popup, state, resolve, reject) 60 | ) 61 | } 62 | 63 | export default authorize 64 | -------------------------------------------------------------------------------- /src/reducers.js: -------------------------------------------------------------------------------- 1 | import { hasToken, getToken, getExpiresAt } from './util/token' 2 | import { LOGIN_REQUEST, LOGIN_SUCCESS, LOGIN_FAILURE, LOGOUT } from './actions' 3 | 4 | const initialState = { 5 | isLoggedIn: hasToken(), 6 | token: getToken(), 7 | expiresAt: getExpiresAt(), 8 | isLoggingIn: false, 9 | error: null 10 | } 11 | 12 | const auth = (state = initialState, action) => { 13 | switch (action.type) { 14 | case LOGIN_REQUEST: 15 | return Object.assign({}, state, { 16 | isLoggingIn: true 17 | }) 18 | case LOGIN_SUCCESS: 19 | return Object.assign({}, state, { 20 | isLoggedIn: true, 21 | token: action.token, 22 | expiresAt: action.expiresAt, 23 | error: null, 24 | isLoggingIn: false 25 | }) 26 | case LOGIN_FAILURE: 27 | return Object.assign({}, state, { 28 | isLoggedIn: false, 29 | token: null, 30 | expiresAt: null, 31 | error: action.error, 32 | isLoggingIn: false 33 | }) 34 | case LOGOUT: 35 | return Object.assign({}, state, { 36 | isLoggedIn: false, 37 | token: null, 38 | expiresAt: null, 39 | error: null, 40 | isLoggingIn: false 41 | }) 42 | default: 43 | return state 44 | } 45 | } 46 | 47 | export default auth 48 | -------------------------------------------------------------------------------- /src/util/popup.js: -------------------------------------------------------------------------------- 1 | const SETTINGS = 2 | 'scrollbars=no,toolbar=no,location=no,titlebar=no,directories=no,status=no,menubar=no' 3 | 4 | const getPopupDimensions = (width, height) => { 5 | const wLeft = window.screenLeft || window.screenX 6 | const wTop = window.screenTop || window.screenY 7 | 8 | const left = wLeft + window.innerWidth / 2 - width / 2 9 | const top = wTop + window.innerHeight / 2 - height / 2 10 | 11 | return `width=${width},height=${height},top=${top},left=${left}` 12 | } 13 | 14 | const openPopup = (url, name, width, height) => 15 | window.open(url, name, `${SETTINGS},${getPopupDimensions(width, height)}`) 16 | 17 | export default openPopup 18 | -------------------------------------------------------------------------------- /src/util/token.js: -------------------------------------------------------------------------------- 1 | const TOKEN_KEY = 'token' 2 | const EXPIRES_AT_KEY = 'expiresAt' 3 | 4 | export const getExpiresAt = () => 5 | Number(window.localStorage.getItem(EXPIRES_AT_KEY)) || null 6 | 7 | export const hasToken = () => getToken() !== null 8 | 9 | export const getToken = () => { 10 | const expiresAt = getExpiresAt() 11 | if (expiresAt === null || expiresAt > Date.now()) { 12 | return window.localStorage.getItem(TOKEN_KEY) || null 13 | } 14 | return null 15 | } 16 | 17 | export const setToken = (token, expiresAt) => { 18 | window.localStorage.setItem(TOKEN_KEY, token) 19 | if (expiresAt !== null) { 20 | window.localStorage.setItem(EXPIRES_AT_KEY, expiresAt) 21 | } else { 22 | window.localStorage.removeItem(EXPIRES_AT_KEY) 23 | } 24 | } 25 | 26 | export const removeToken = () => { 27 | window.localStorage.removeItem(TOKEN_KEY) 28 | window.localStorage.removeItem(EXPIRES_AT_KEY) 29 | } 30 | -------------------------------------------------------------------------------- /src/util/token.test.js: -------------------------------------------------------------------------------- 1 | import { 2 | getToken, 3 | getExpiresAt, 4 | hasToken, 5 | setToken, 6 | removeToken 7 | } from './token' 8 | 9 | const now = Date.now() 10 | 11 | beforeAll(() => { 12 | jest.spyOn(Date, 'now').mockImplementation(() => now) 13 | }) 14 | 15 | afterAll(() => { 16 | jest.restoreAllMocks() 17 | }) 18 | 19 | beforeEach(() => { 20 | localStorage.clear() 21 | }) 22 | 23 | test('empty localStorage returns null for token', () => { 24 | expect(getToken()).toBeNull() 25 | }) 26 | 27 | test('empty localStorage returns null for expiresAt', () => { 28 | expect(getExpiresAt()).toBeNull() 29 | }) 30 | 31 | test('empty localStorage has no token', () => { 32 | expect(hasToken()).toBeFalsy() 33 | }) 34 | 35 | test('localStorage returns token after setting it', () => { 36 | expect(getToken()).toBeNull() 37 | setToken('token', null) 38 | expect(getToken()).toBe('token') 39 | }) 40 | 41 | test('localStorage returns expiresAt after setting it', () => { 42 | expect(getExpiresAt()).toBeNull() 43 | setToken('token', now) 44 | expect(getExpiresAt()).toBe(now) 45 | }) 46 | 47 | test('localStorage has token after setting it', () => { 48 | expect(hasToken()).toBeFalsy() 49 | setToken('token', null) 50 | expect(hasToken()).toBeTruthy() 51 | }) 52 | 53 | test('localStorage returns null for token after removing it', () => { 54 | setToken('token', null) 55 | expect(getToken()).toBe('token') 56 | removeToken() 57 | expect(getToken()).toBeNull() 58 | }) 59 | 60 | test('localStorage returns null for expiresAt after removing it', () => { 61 | setToken('token', now) 62 | expect(getExpiresAt()).toBe(now) 63 | removeToken() 64 | expect(getToken()).toBeNull() 65 | }) 66 | 67 | test('localStorage has no token after removing it', () => { 68 | setToken('token', null) 69 | expect(hasToken()).toBeTruthy() 70 | removeToken() 71 | expect(hasToken()).toBeFalsy() 72 | }) 73 | 74 | test('localStorage returns token after setting it when not expired', () => { 75 | expect(getToken()).toBeNull() 76 | setToken('token', now + 1) 77 | expect(getToken()).toBe('token') 78 | }) 79 | 80 | test('localStorage returns null for token after setting it when expired', () => { 81 | expect(getToken()).toBeNull() 82 | setToken('token', now) 83 | expect(getToken()).toBeNull() 84 | }) 85 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | var webpack = require('webpack') 2 | 3 | var config = { 4 | module: { 5 | rules: [{ test: /\.js$/, loader: 'babel-loader', exclude: /node_modules/ }] 6 | }, 7 | output: { 8 | library: 'ReduxImplicitOAuth2', 9 | libraryTarget: 'umd' 10 | }, 11 | plugins: [ 12 | new webpack.DefinePlugin({ 13 | 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV) 14 | }) 15 | ] 16 | } 17 | 18 | if (process.env.NODE_ENV === 'production') { 19 | config.plugins.push( 20 | new webpack.optimize.UglifyJsPlugin({ 21 | compressor: { 22 | warnings: false, 23 | screw_ie8: false 24 | }, 25 | mangle: { 26 | screw_ie8: false 27 | }, 28 | output: { 29 | screw_ie8: false 30 | } 31 | }) 32 | ) 33 | } 34 | 35 | module.exports = config 36 | --------------------------------------------------------------------------------