├── .babelrc ├── .gitignore ├── .nvmrc ├── .travis.yml ├── LICENSE.md ├── README.md ├── demo ├── .acl ├── demo.css ├── favicon.png └── index.html ├── package-lock.json ├── package.json ├── src ├── index.js └── provider-select-popup.js ├── test └── auth.test.js └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015"] 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 18 | .grunt 19 | 20 | # node-waf configuration 21 | .lock-wscript 22 | 23 | # Compiled binary addons (http://nodejs.org/api/addons.html) 24 | build/Release 25 | 26 | # Dependency directory 27 | node_modules 28 | 29 | # Optional npm cache directory 30 | .npm 31 | 32 | # Optional REPL history 33 | .node_repl_history 34 | 35 | # Build dirs 36 | lib 37 | dist 38 | demo/solid-client.min.js* 39 | 40 | .nyc_output 41 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 12 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: node_js 3 | node_js: 4 | - "10.12" 5 | - "12.0" 6 | 7 | cache: 8 | directories: 9 | - node_modules 10 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 - present 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 | # solid-auth-oidc 2 | [![](https://img.shields.io/badge/project-Solid-7C4DFF.svg?style=flat)](https://github.com/solid/solid) 3 | [![NPM Version](https://img.shields.io/npm/v/solid-auth-oidc.svg?style=flat)](https://npm.im/solid-auth-oidc) 4 | 5 | A Javascript authentication plugin for 6 | [`solid-client`](https://github.com/solid/solid-client) based on OAuth2/OpenID 7 | Connect. 8 | 9 | This is an Authentication helper library that wraps an OpenID Connect (OIDC) 10 | Relying Party library, [`oidc-rp`](https://github.com/anvilresearch/oidc-rp). 11 | It is meant to be used in browser-side applications, as part of `solid-client`. 12 | 13 | ### Usage 14 | 15 | ##### currentUser 16 | 17 | `Promise currentUser()` 18 | 19 | Resolves to the WebID URI of the currently authenticated user, or `null` if none 20 | found. 21 | 22 | This SHOULD be checked either on page load or on whatever "Application is 23 | ready" event that your framework provides. For example: 24 | 25 | ```js 26 | // Using a standard "document loaded" event listener 27 | // (equivalent to jQuery's $(document).ready()) 28 | document.addEventListener('DOMContentLoaded', function () { 29 | solidClient.currentUser() 30 | .then(function (webId) { 31 | if (webId) { 32 | // User is logged in, you can display their webId, load their profile, etc 33 | } else { 34 | // Not logged in, display appropriate Login button / UI 35 | } 36 | }) 37 | .catch(function (error) { 38 | // An error has occurred, display it to user 39 | }) 40 | }) 41 | ``` 42 | 43 | ##### login 44 | 45 | `Promise login([string providerUri])` 46 | 47 | This is the main "authenticate to your favorite server/identity provider" 48 | action, which can be hooked up to whatever 'Login' button or link that your 49 | UI provides. 50 | 51 | App developers will use it in one of two ways: 52 | 53 | a) (typical) Your app does not provide its own Select Provider UI, so you can 54 | just call `.login()` by itself with no parameter, which uses the built-in 55 | provider selection UI. 56 | b) Your app *does* provide its own Select Provider UI. In this case, you can 57 | perform provider selection and pass in the `providerUri` to `.login()` 58 | directly. 59 | 60 | Called by itself (without a `providerUri`), `login()` does the following: 61 | 62 | 1. If the user has already logged in, it resolves with their WebID URI 63 | 2. Otherwise, opens a 'Select Provider' popup window, asking the user to select 64 | their identity provider (Solid server, pod, etc) to login to. 65 | 3. The user makes their selection, and the popup closes and the current page 66 | is redirected to that provider's `/authorize` endpoint 67 | 4. When the user has gone through the local login process etc, they are 68 | redirected back to the current page (from which `login()` was invoked) 69 | 70 | If `login()` is called *with* a `providerUri` argument, the Select Provider 71 | popup window step is skipped, and the user proceeds directly to the auth 72 | workflow. 73 | 74 | ```js 75 | // You can bind any sort of Login button or link to do the following: 76 | solidClient.login() 77 | .then(function (webId) { 78 | // User is logged in, you can display their webId, load their profile, etc 79 | }) 80 | .catch(function (error) { 81 | // An error has occurred while logging in, display it to user 82 | }) 83 | ``` 84 | 85 | After `login()` is successful, the following variables are set: 86 | 87 | - `solidClient.auth.webId` is set to the current user's webId URI 88 | - `solidClient.auth.accessToken` is set to the current user's access token 89 | 90 | ##### selectProvider 91 | 92 | `Promise selectProvider ([string providerUri])` 93 | 94 | ##### logout 95 | 96 | `logout()` 97 | 98 | Clears the current user and tokens, and does a url redirect to the current 99 | RP client's provider's 'end session' endpoint. A redirect is done (instead of an 100 | ajax 'get') to enable the provider to clear any http-only session cookies. 101 | -------------------------------------------------------------------------------- /demo/.acl: -------------------------------------------------------------------------------- 1 | @prefix rdf: . 2 | 3 | <#Public> 4 | a ; 5 | <.> ; 6 | ; 7 | <.> ; 8 | . 9 | -------------------------------------------------------------------------------- /demo/demo.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin-bottom: 3em; 3 | } 4 | .demo-section { 5 | margin-top: 2em; 6 | border-top: 1px solid gray; 7 | } 8 | -------------------------------------------------------------------------------- /demo/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nodeSolidServer/solid-auth-oidc/079c5dd79a5ff91abc1662f4b68f8d81648c241f/demo/favicon.png -------------------------------------------------------------------------------- /demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Solid Auth Demo App 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 |
18 |

Solid Auth Demo Page

19 |
20 |
21 |

Login, invoked by itself, will open a default Provider Select popup window.

22 | 23 |
24 |
25 |
App body
26 |

The rest of the app goes here

27 |
28 |
29 |
WebId
30 |
31 |
32 |
Not signed in
33 | 36 |
37 |
38 |

39 |
40 |
41 | 44 |
45 |
46 |
47 |
48 | 49 |
50 |
51 |
52 |
53 |
54 | 55 | 147 | 148 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@solid/solid-auth-oidc", 3 | "version": "0.5.6", 4 | "engines": { 5 | "node": ">= 6.0" 6 | }, 7 | "description": "Authentication library for Solid client based on OAuth2/OpenID Connect", 8 | "main": "./lib/index.js", 9 | "files": [ 10 | "lib", 11 | "dist" 12 | ], 13 | "scripts": { 14 | "build": "npm run build-lib", 15 | "build-dist": "webpack --progress --colors --optimize-minimize", 16 | "build-lib": "babel src -d lib", 17 | "dist": "npm run build && npm run build-dist", 18 | "mocha": "nyc mocha test/*.js", 19 | "postversion": "git push --follow-tags", 20 | "prepublish": "npm run build && npm run test", 21 | "preversion": "npm test", 22 | "standard": "standard src/*", 23 | "test": "npm run standard && npm run mocha" 24 | }, 25 | "nyc": { 26 | "reporter": [ 27 | "html", 28 | "text-summary" 29 | ], 30 | "cache": true 31 | }, 32 | "repository": { 33 | "type": "git", 34 | "url": "https://github.com/solid/solid-auth-oidc" 35 | }, 36 | "keywords": [ 37 | "authentication", 38 | "oidc", 39 | "openid", 40 | "oauth", 41 | "oauth2", 42 | "webid", 43 | "solid", 44 | "decentralized", 45 | "web", 46 | "rdf", 47 | "ldp", 48 | "linked", 49 | "data", 50 | "rest" 51 | ], 52 | "author": "Dmitri Zagidulin ", 53 | "license": "MIT", 54 | "bugs": { 55 | "url": "https://github.com/solid/solid-auth-oidc/issues" 56 | }, 57 | "homepage": "https://github.com/solid/solid-auth-oidc", 58 | "dependencies": { 59 | "@solid/oidc-rp": "^0.11.7" 60 | }, 61 | "devDependencies": { 62 | "babel-cli": "^6.26.0", 63 | "babel-core": "^6.26.3", 64 | "babel-loader": "^8.2.5", 65 | "babel-preset-es2015": "^6.24.1", 66 | "chai": "^4.3.6", 67 | "chai-as-promised": "^7.1.1", 68 | "dirty-chai": "^2.0.1", 69 | "localstorage-memory": "^1.0.3", 70 | "mocha": "^8.4.0", 71 | "nyc": "^15.1.0", 72 | "sinon": "^9.2.4", 73 | "sinon-chai": "^3.7.0", 74 | "standard": "^16.0.4", 75 | "webpack": "^5.74.0", 76 | "whatwg-url": "^8.7.0" 77 | }, 78 | "standard": { 79 | "globals": [ 80 | "localStorage", 81 | "URL", 82 | "URLSearchParams" 83 | ] 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | /* 2 | The MIT License (MIT) 3 | 4 | Copyright (c) 2016-17 Solid 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | 24 | If you would like to know more about the solid Solid project, please see 25 | https://github.com/solid/solid 26 | */ 27 | 'use strict' 28 | const RelyingParty = require('@solid/oidc-rp') 29 | const PoPToken = require('@solid/oidc-rp/src/PoPToken') 30 | const providerSelectPopupSource = require('./provider-select-popup') 31 | 32 | // URI parameter types 33 | const HASH = 'hash' 34 | const QUERY = 'query' 35 | 36 | // AuthenticationRequest sending methods 37 | const REDIRECT = 'redirect' 38 | 39 | class ClientAuthOIDC { 40 | /** 41 | * @constructor 42 | * @param [options={}] 43 | * @param [options.window=Window] Optionally inject global browser window 44 | * @param [options.store=localStorage] Optionally inject localStorage 45 | */ 46 | constructor (options = {}) { 47 | this.window = options.window || global.window 48 | this.store = options.store || global.localStorage 49 | 50 | this.currentClient = null 51 | this.providerUri = null 52 | this.webId = null 53 | this.idToken = null 54 | this.accessToken = null 55 | this.method = REDIRECT // only redirect is currently supported 56 | } 57 | 58 | initEventListeners (window) { 59 | window.addEventListener('message', this.onMessage.bind(this)) 60 | } 61 | 62 | /** 63 | * Returns the current window's URI 64 | * 65 | * @return {string|null} 66 | */ 67 | currentLocation () { 68 | const window = this.window 69 | 70 | if (!window || !window.location) { return null } 71 | 72 | return window.location.href 73 | } 74 | 75 | /** 76 | * @return {Promise} Resolves to current user's WebID URI 77 | */ 78 | currentUser () { 79 | if (this.webId) { 80 | return Promise.resolve(this.webId) 81 | } 82 | 83 | // Attempt to find a provider based on the 'state' param of the current URI 84 | const providerUri = this.providerFromCurrentUri() 85 | 86 | if (providerUri) { 87 | return this.login(providerUri) 88 | } else { 89 | return Promise.resolve(null) 90 | } 91 | } 92 | 93 | /** 94 | * Returns the 'end session' api endpoint of the current RP client's provider 95 | * (e.g. 'https://example.com/logout'), if one is available. 96 | * 97 | * @return {string|null} 98 | */ 99 | providerEndSessionEndpoint () { 100 | const rp = this.currentClient 101 | 102 | if (!rp || !rp.provider || !rp.provider.configuration) { return null } 103 | 104 | const config = rp.provider.configuration 105 | 106 | if (!config.end_session_endpoint) { return null } 107 | 108 | return config.end_session_endpoint 109 | } 110 | 111 | /** 112 | * Extracts and returns the `state` query or hash fragment param from a uri 113 | * 114 | * @param uri {string} 115 | * @param uriType {string} 'hash' or 'query' 116 | * 117 | * @return {string|null} Value of the `state` query or hash fragment param 118 | */ 119 | extractState (uri, uriType = HASH) { 120 | if (!uri) { return null } 121 | const uriObj = new URL(uri) 122 | let state 123 | 124 | if (uriType === HASH) { 125 | const hash = uriObj.hash || '#' 126 | const params = new URLSearchParams(hash.substr(1)) 127 | state = params.get('state') 128 | } 129 | 130 | if (uriType === QUERY) { 131 | state = uriObj.searchParams.get('state') 132 | } 133 | 134 | return state 135 | } 136 | 137 | keyByProvider (providerUri) { 138 | return `oidc.rp.by-provider.${providerUri}` 139 | } 140 | 141 | keyByState (state) { 142 | if (!state) { 143 | throw new TypeError('No state provided to keyByState()') 144 | } 145 | return `oidc.rp.by-state.${state}` 146 | } 147 | 148 | /** 149 | * @param providerUri {string} 150 | * 151 | * @return {Promise} 152 | */ 153 | loadOrRegisterClient (providerUri) { 154 | this.currentClient = null 155 | 156 | return this.loadClient(providerUri) 157 | .then(loadedClient => { 158 | if (loadedClient) { 159 | this.currentClient = loadedClient 160 | return loadedClient 161 | } else { 162 | this.currentClient = null 163 | return this.registerClient(providerUri) 164 | } 165 | }) 166 | } 167 | 168 | /** 169 | * @param providerUri {string} 170 | * @return {Promise} 171 | */ 172 | loadClient (providerUri) { 173 | if (!providerUri) { 174 | const error = new Error('Cannot load or register client, providerURI missing') 175 | return Promise.reject(error) 176 | } 177 | if (this.currentClient && this.currentClient.provider.url === providerUri) { 178 | // Client is cached, return it 179 | return Promise.resolve(this.currentClient) 180 | } 181 | 182 | // Check for client config stored locally 183 | const key = this.keyByProvider(providerUri) 184 | let clientConfig = this.store.getItem(key) 185 | 186 | if (clientConfig) { 187 | clientConfig = JSON.parse(clientConfig) 188 | return RelyingParty.from(clientConfig) 189 | } else { 190 | return Promise.resolve(null) 191 | } 192 | } 193 | 194 | /** 195 | * Loads a provider's URI from store, given a `state` uri param. 196 | * @param state {string} 197 | * @return {string} 198 | */ 199 | loadProvider (state) { 200 | const key = this.keyByState(state) 201 | const providerUri = this.store.getItem(key) 202 | return providerUri 203 | } 204 | 205 | /** 206 | * Resolves to the WebID URI of the current user. Intended to be triggered 207 | * when the user initiates login explicitly (such as by pressing a Login 208 | * button, etc). 209 | * 210 | * @param [providerUri] {string} Provider URI, result of a Provider Selection 211 | * operation (that the app developer has provided). If `null`, the 212 | * `selectProvider()` step will kick off its own UI for Provider Selection. 213 | * 214 | * @return {Promise} Resolves to the logged in user's WebID URI 215 | */ 216 | login (providerUri) { 217 | this.clearCurrentUser() 218 | 219 | return Promise.resolve(providerUri) 220 | .then(providerUri => this.selectProvider(providerUri)) 221 | .then(selectedProviderUri => { 222 | if (selectedProviderUri) { 223 | return this.loadOrRegisterClient(selectedProviderUri) 224 | } 225 | }) 226 | .then(client => { 227 | if (client) { 228 | return this.validateOrSendAuthRequest(client) 229 | } 230 | }) 231 | } 232 | 233 | clearCurrentUser () { 234 | this.webId = null 235 | this.accessToken = null 236 | this.idToken = null 237 | } 238 | 239 | /** 240 | * Clears the current user and tokens, and does a url redirect to the 241 | * current RP client's provider's 'end session' endpoint. 242 | * A redirect is done (instead of an ajax 'get') to enable the provider to 243 | * clear any http-only session cookies. 244 | */ 245 | logout () { 246 | this.clearCurrentUser() 247 | 248 | const logoutEndpoint = this.providerEndSessionEndpoint() 249 | 250 | if (!logoutEndpoint) { return } 251 | 252 | const logoutUrl = new URL(logoutEndpoint) 253 | 254 | logoutUrl.searchParams.set('returnToUrl', this.currentLocation()) 255 | 256 | this.redirectTo(logoutUrl.toString()) 257 | } 258 | 259 | /** 260 | * Resolves to the URI of an OIDC identity provider, from one of the following: 261 | * 262 | * 1. If a `providerUri` was passed in by the app developer (perhaps they 263 | * developed a custom 'Select Provider' UI), that value is returned. 264 | * 2. The current `this.providerUri` cached on this auth client, if present 265 | * 3. The `state` parameter of the current window URI (in case the user has 266 | * gone through the login workflow and this page is the redirect back). 267 | * 3. Lastly, if none of the above worked, the clients opens its own 268 | * 'Select Provider' UI popup window, and sets up an event listener (for 269 | * when a user makes a selection. 270 | * 271 | * @param [providerUri] {string} If the provider URI is already known to the 272 | * app developer, just pass it through, no need to take further action. 273 | * @return {Promise} 274 | */ 275 | selectProvider (providerUri) { 276 | if (providerUri) { 277 | return Promise.resolve(providerUri) 278 | } 279 | 280 | // Attempt to find a provider based on the 'state' param of the current URI 281 | providerUri = this.providerFromCurrentUri() 282 | if (providerUri) { 283 | return Promise.resolve(providerUri) 284 | } 285 | 286 | // Lastly, kick off a Select Provider popup window workflow 287 | return this.providerFromUI() 288 | } 289 | 290 | /** 291 | * Parses the current URI's `state` hash param and attempts to load a 292 | * previously saved providerUri from it. If no `state` param is present, or if 293 | * no providerUri has been saved, returns `null`. 294 | * 295 | * @return {string|null} Provider URI, if present 296 | */ 297 | providerFromCurrentUri () { 298 | const currentUri = this.currentLocation() 299 | const stateParam = this.extractState(currentUri, HASH) 300 | 301 | if (stateParam) { 302 | return this.loadProvider(stateParam) 303 | } else { 304 | return null 305 | } 306 | } 307 | 308 | providerFromUI () { 309 | console.log('Getting provider from default popup UI') 310 | this.initEventListeners(this.window) 311 | 312 | if (this.selectProviderWindow) { 313 | // Popup has already been opened 314 | this.selectProviderWindow.focus() 315 | } else { 316 | // Open a new Provider Select popup window 317 | this.selectProviderWindow = this.window.open('', 318 | 'selectProviderWindow', 319 | 'menubar=no,resizable=yes,width=300,height=300' 320 | ) 321 | 322 | this.selectProviderWindow.document.write(providerSelectPopupSource) 323 | this.selectProviderWindow.document.close() 324 | } 325 | } 326 | 327 | /** 328 | * Tests whether the current URI is the result of an AuthenticationRequest 329 | * return redirect. 330 | * @return {boolean} 331 | */ 332 | currentUriHasAuthResponse () { 333 | const currentUri = this.currentLocation() 334 | const stateParam = this.extractState(currentUri, HASH) 335 | 336 | return !!stateParam 337 | } 338 | 339 | /** 340 | * Redirects the current window to the given uri. 341 | * @param uri {string} 342 | */ 343 | redirectTo (uri) { 344 | this.window.location.href = uri 345 | 346 | return false 347 | } 348 | 349 | /** 350 | * @private 351 | * @param client {RelyingParty} 352 | * @throws {Error} 353 | * @return {Promise} 354 | */ 355 | sendAuthRequest (client) { 356 | const options = {} 357 | const providerUri = client.provider.url 358 | 359 | return client.createRequest(options, this.store) 360 | .then(authUri => { 361 | const state = this.extractState(authUri, QUERY) 362 | if (!state) { 363 | throw new Error('Invalid authentication request uri') 364 | } 365 | this.saveProviderByState(state, providerUri) 366 | if (this.method === REDIRECT) { 367 | return this.redirectTo(authUri) 368 | } 369 | }) 370 | } 371 | 372 | /** 373 | * @param client {RelyingParty} 374 | * @throws {Error} 375 | * @return {Promise} Resolves to either an AuthenticationRequest 376 | * being sent (`null`), or to the webId of the current user (extracted 377 | * from the authentication response). 378 | */ 379 | validateOrSendAuthRequest (client) { 380 | if (!client) { 381 | const error = new Error('Could not load or register a RelyingParty client') 382 | return Promise.reject(error) 383 | } 384 | 385 | if (this.currentUriHasAuthResponse()) { 386 | return this.initUserFromResponse(client) 387 | } 388 | 389 | return this.sendAuthRequest(client) 390 | } 391 | 392 | issuePoPTokenFor (uri, session) { 393 | return PoPToken.issueFor(uri, session) 394 | } 395 | 396 | /** 397 | * Validates the auth response in the current uri, initializes the current 398 | * user's ID Token and Access token, and returns the user's WebID 399 | * 400 | * @param client {RelyingParty} 401 | * 402 | * @throws {Error} 403 | * 404 | * @returns {Promise} Current user's web id 405 | */ 406 | initUserFromResponse (client) { 407 | return client.validateResponse(this.currentLocation(), this.store) 408 | .then(response => { 409 | this.idToken = response.authorization.id_token 410 | this.accessToken = response.authorization.access_token 411 | this.session = response 412 | 413 | this.clearAuthResponseFromUrl() 414 | 415 | return this.extractAndValidateWebId(response.idClaims.sub) 416 | }) 417 | .catch(error => { 418 | this.clearAuthResponseFromUrl() 419 | if (error.message === 'Cannot resolve signing key for ID Token.') { 420 | console.log('ID Token found, but could not validate. Provider likely has changed their public keys. Please retry login.') 421 | return null 422 | } else { 423 | throw error 424 | } 425 | }) 426 | } 427 | 428 | /** 429 | * @param idToken {IDToken} 430 | * 431 | * @throws {Error} 432 | * 433 | * @return {string} 434 | */ 435 | extractAndValidateWebId (idToken) { 436 | const webId = idToken 437 | this.webId = webId 438 | return webId 439 | } 440 | 441 | /** 442 | * Removes authentication response data (access token, id token etc) from 443 | * the current url's hash fragment. 444 | */ 445 | clearAuthResponseFromUrl () { 446 | const clearedUrl = this.currentLocationNoHash() 447 | 448 | this.replaceCurrentUrl(clearedUrl) 449 | } 450 | 451 | currentLocationNoHash () { 452 | const currentLocation = this.currentLocation() 453 | if (!currentLocation) { return null } 454 | 455 | const currentUrl = new URL(this.currentLocation()) 456 | currentUrl.hash = '' // remove the hash fragment 457 | const clearedUrl = currentUrl.toString() 458 | 459 | return clearedUrl 460 | } 461 | 462 | replaceCurrentUrl (newUrl) { 463 | const history = this.window.history 464 | 465 | if (!history) { return } 466 | 467 | history.replaceState(history.state, history.title, newUrl) 468 | } 469 | 470 | /** 471 | * @param providerUri {string} 472 | * @param [options={}] 473 | * @param [options.redirectUri] {string} Defaults to window.location.href 474 | * @param [options.scope='openid profile'] {string} 475 | * @throws {TypeError} If providerUri is missing 476 | * @return {Promise} Registered RelyingParty client instance 477 | */ 478 | registerClient (providerUri, options = {}) { 479 | return this.registerPublicClient(providerUri, options) 480 | .then(registeredClient => { 481 | this.storeClient(registeredClient, providerUri) 482 | return registeredClient 483 | }) 484 | } 485 | 486 | /** 487 | * @private 488 | * @param providerUri {string} 489 | * @param [options={}] 490 | * @param [options.redirectUri] {string} Defaults to window.location.href 491 | * @param [options.scope='openid profile'] {string} 492 | * @throws {TypeError} If providerUri is missing 493 | * @return {Promise} Registered RelyingParty client instance 494 | */ 495 | registerPublicClient (providerUri, options = {}) { 496 | console.log('Registering public client...') 497 | if (!providerUri) { 498 | throw new TypeError('Cannot registerClient auth client, missing providerUri') 499 | } 500 | const redirectUri = options.redirectUri || this.currentLocation() 501 | this.redirectUri = redirectUri 502 | const registration = { 503 | issuer: providerUri, 504 | grant_types: ['implicit'], 505 | redirect_uris: [redirectUri], 506 | response_types: ['id_token token'], 507 | scope: options.scope || 'openid profile' 508 | } 509 | const rpOptions = { 510 | defaults: { 511 | authenticate: { 512 | redirect_uri: redirectUri, 513 | response_type: 'id_token token' 514 | } 515 | }, 516 | store: this.store 517 | } 518 | return RelyingParty 519 | .register(providerUri, registration, rpOptions) 520 | } 521 | 522 | onMessage (event) { 523 | console.log('Auth client received event: ', event) 524 | if (!event || !event.data) { return } 525 | switch (event.data.event_type) { 526 | case 'providerSelected': 527 | // eslint-disable-next-line no-case-declarations 528 | const providerUri = event.data.value 529 | console.log('Provider selected: ', providerUri) 530 | this.login(providerUri) 531 | this.selectProviderWindow.close() 532 | break 533 | default: 534 | console.error('onMessage - unknown event type: ', event) 535 | break 536 | } 537 | } 538 | 539 | /** 540 | * @param state {string} 541 | * @param providerUri {string} 542 | * @throws {Error} 543 | */ 544 | saveProviderByState (state, providerUri) { 545 | if (!state) { 546 | throw new Error('Cannot save providerUri - state not provided') 547 | } 548 | const key = this.keyByState(state) 549 | this.store.setItem(key, providerUri) 550 | } 551 | 552 | /** 553 | * Stores a RelyingParty client for a given provider in the local store. 554 | * @param client {RelyingParty} 555 | * @param providerUri {string} 556 | */ 557 | storeClient (client, providerUri) { 558 | this.currentClient = client 559 | this.store.setItem(this.keyByProvider(providerUri), client.serialize()) 560 | } 561 | } 562 | 563 | module.exports = ClientAuthOIDC 564 | -------------------------------------------------------------------------------- /src/provider-select-popup.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | module.exports = ` 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 |
14 |
15 | Login with: 16 |
17 |
18 |
19 |
20 | 23 |
24 |
25 |
26 |
27 | or custom:
28 | 29 | 30 |
31 |
32 |
33 | 68 | 69 | 70 | ` 71 | -------------------------------------------------------------------------------- /test/auth.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /* global describe, it, beforeEach, before, after */ 3 | 4 | global.URL = require('whatwg-url').URL 5 | global.URLSearchParams = require('whatwg-url').URLSearchParams 6 | 7 | const localStorage = require('localstorage-memory') 8 | 9 | const chai = require('chai') 10 | const sinon = require('sinon') 11 | const sinonChai = require('sinon-chai') 12 | const chaiAsPromised = require('chai-as-promised') 13 | chai.use(sinonChai) 14 | chai.use(chaiAsPromised) 15 | chai.use(require('dirty-chai')) 16 | chai.should() 17 | 18 | const expect = chai.expect 19 | 20 | const SolidAuthOIDC = require('../src/index') 21 | const PoPToken = require('@solid/oidc-rp/src/PoPToken') 22 | 23 | describe('SolidAuthOIDC', () => { 24 | var auth 25 | const providerUri = 'https://provider.example.com' 26 | 27 | beforeEach(() => { 28 | localStorage.clear() 29 | auth = new SolidAuthOIDC({ window: { location: {} }, store: localStorage }) 30 | }) 31 | 32 | describe('login()', () => { 33 | it('should invoke selectProvider() if provider uri is not given', () => { 34 | const selectProvider = sinon.stub(auth, 'selectProvider').resolves(null) 35 | 36 | return auth.login() 37 | .then(() => { 38 | expect(selectProvider).to.have.been.called() 39 | }) 40 | }) 41 | 42 | it('should invoke selectProvider() with a given provider uri', () => { 43 | const selectProvider = sinon.stub(auth, 'selectProvider').resolves(null) 44 | 45 | return auth.login(providerUri) 46 | .then(() => { 47 | expect(selectProvider).to.have.been.calledWith(providerUri) 48 | }) 49 | }) 50 | 51 | it('should load a client for a given provider uri', () => { 52 | const loadOrRegisterClient = sinon.stub(auth, 'loadOrRegisterClient') 53 | .resolves(null) 54 | 55 | return auth.login(providerUri) 56 | .then(() => { 57 | expect(loadOrRegisterClient).to.have.been.calledWith(providerUri) 58 | }) 59 | }) 60 | 61 | it('should validate a loaded client for a given provider uri', () => { 62 | const mockClient = {} 63 | 64 | sinon.stub(auth, 'loadOrRegisterClient').resolves(mockClient) 65 | 66 | const validateStub = sinon.stub(auth, 'validateOrSendAuthRequest') 67 | 68 | return auth.login(providerUri) 69 | .then(() => { 70 | expect(validateStub).to.have.been.calledWith(mockClient) 71 | }) 72 | }) 73 | }) 74 | 75 | describe('logout()', () => { 76 | it('should clear the current user', () => { 77 | const clearCurrentUser = sinon.spy(auth, 'clearCurrentUser') 78 | 79 | auth.logout() 80 | expect(clearCurrentUser).to.have.been.called() 81 | }) 82 | 83 | it('should not redirect if no current client exists', () => { 84 | const redirectTo = sinon.spy(auth, 'redirectTo') 85 | 86 | auth.logout() 87 | expect(redirectTo).to.not.have.been.called() 88 | }) 89 | 90 | it('should redirect to the provider end session endpoint', () => { 91 | auth.window = { location: {} } 92 | 93 | const logoutEndpoint = 'https://example.com/logout' 94 | auth.providerEndSessionEndpoint = sinon.stub().returns(logoutEndpoint) 95 | 96 | const currentUrl = 'https://rp.com' 97 | auth.currentLocation = sinon.stub().returns(currentUrl) 98 | 99 | const redirectTo = sinon.spy(auth, 'redirectTo') 100 | 101 | auth.logout() 102 | 103 | expect(redirectTo) 104 | .to.have.been.calledWith('https://example.com/logout?returnToUrl=' + 105 | encodeURIComponent(currentUrl)) 106 | }) 107 | }) 108 | 109 | describe('keyByState()', () => { 110 | it('should throw an error if no state param is passed to it', () => { 111 | const auth = new SolidAuthOIDC() 112 | 113 | expect(auth.keyByState).to.throw(/No state provided/) 114 | }) 115 | 116 | it('should compose a key from the state param', () => { 117 | const auth = new SolidAuthOIDC() 118 | const key = auth.keyByState('abcd') 119 | 120 | expect(key).to.equal('oidc.rp.by-state.abcd') 121 | }) 122 | }) 123 | 124 | describe('providerFromCurrentUri()', () => { 125 | it('should return null when no state param present', () => { 126 | auth.window.location.href = 'https://client-app.example.com' 127 | const providerUri = auth.providerFromCurrentUri() 128 | 129 | expect(providerUri).to.not.exist() 130 | }) 131 | 132 | it('should return null if no provider was saved', () => { 133 | const state = 'abcd' 134 | auth.window.location.href = `https://client-app.example.com#state=${state}` 135 | const loadedProviderUri = auth.providerFromCurrentUri() 136 | 137 | expect(loadedProviderUri).to.not.exist() 138 | }) 139 | 140 | it('should load provider from current uri state param', () => { 141 | const providerUri = 'https://provider.example.com' 142 | const state = 'abcd' 143 | auth.saveProviderByState(state, providerUri) 144 | auth.window.location.href = `https://client-app.example.com#state=${state}` 145 | 146 | const loadedProviderUri = auth.providerFromCurrentUri() 147 | 148 | expect(loadedProviderUri).to.equal(providerUri) 149 | }) 150 | }) 151 | 152 | describe('provider persistence', () => { 153 | it('should store and load provider uri, by state', () => { 154 | const state = 'abcd' 155 | // Check to see that provider doesn't exist initially 156 | expect(auth.loadProvider(state)).to.not.exist() 157 | 158 | // Save the provider uri to local storage 159 | auth.saveProviderByState(state, providerUri) 160 | 161 | // Check that it was saved and can be loaded 162 | expect(auth.loadProvider(state)).to.equal(providerUri) 163 | }) 164 | }) 165 | 166 | describe('extractState()', () => { 167 | it('should return null when no uri is provided', () => { 168 | const state = auth.extractState() 169 | 170 | expect(state).to.not.exist() 171 | }) 172 | 173 | it('should return null when uri has no query or hash fragment', () => { 174 | const state = auth.extractState('https://example.com') 175 | 176 | expect(state).to.not.exist() 177 | }) 178 | 179 | it('should extract the state param from query fragments', () => { 180 | let uri = 'https://example.com?param1=value1&state=abcd' 181 | let state = auth.extractState(uri, 'query') 182 | 183 | expect(state).to.equal('abcd') 184 | 185 | uri = 'https://example.com?param1=value1' 186 | state = auth.extractState(uri, 'query') 187 | 188 | expect(state).to.not.exist() 189 | }) 190 | 191 | it('should extract the state param from hash fragments', () => { 192 | let uri = 'https://example.com#param1=value1&state=abcd' 193 | let state = auth.extractState(uri) // 'hash' is the default second param 194 | 195 | expect(state).to.equal('abcd') 196 | 197 | uri = 'https://example.com#param1=value1' 198 | state = auth.extractState(uri, 'hash') 199 | 200 | expect(state).to.not.exist() 201 | }) 202 | }) 203 | 204 | describe('selectProvider()', () => { 205 | it('should pass through a given providerUri', () => { 206 | expect(auth.selectProvider(providerUri)).to.eventually.equal(providerUri) 207 | }) 208 | 209 | it('should derive a provider from the current uri', () => { 210 | auth.providerFromCurrentUri = sinon.stub().returns(providerUri) 211 | 212 | return auth.selectProvider() 213 | .then(selectedProvider => { 214 | expect(selectedProvider).to.equal(providerUri) 215 | expect(auth.providerFromCurrentUri).to.have.been.called() 216 | }) 217 | }) 218 | 219 | it('should obtain provider from UI, if not present or cached', () => { 220 | auth.providerFromCurrentUri = sinon.stub().returns(null) 221 | auth.providerFromUI = sinon.stub().resolves(providerUri) 222 | 223 | return auth.selectProvider() 224 | .then(selectedProvider => { 225 | expect(selectedProvider).to.equal(providerUri) 226 | expect(auth.providerFromUI).to.have.been.called() 227 | }) 228 | }) 229 | }) 230 | 231 | describe('client persistence', () => { 232 | const clientConfig = { provider: { url: providerUri } } 233 | const mockClient = { 234 | provider: { url: providerUri }, 235 | serialize: () => { return clientConfig } 236 | } 237 | 238 | describe('loadClient()', () => { 239 | it('should throw an error if no providerUri given', () => { 240 | expect(auth.loadClient()).to.be.rejected() 241 | }) 242 | 243 | it('should return cached client if for the same provider', () => { 244 | auth.currentClient = mockClient 245 | 246 | expect(auth.loadClient(providerUri)).to.eventually.equal(mockClient) 247 | }) 248 | 249 | it('should NOT return cached client if for different provider', () => { 250 | const providerUri = 'https://provider.example.com' 251 | auth.currentClient = { 252 | provider: { url: 'https://another.provider.com' } 253 | } 254 | 255 | expect(auth.loadClient(providerUri)).to.eventually.not.exist() 256 | }) 257 | }) 258 | 259 | it('should store and load serialized clients', () => { 260 | auth.storeClient(mockClient, providerUri) 261 | // Storing a client should cache it in the auth client 262 | expect(auth.currentClient).to.equal(mockClient) 263 | 264 | return auth.loadClient(providerUri) 265 | .then(loadedClient => { 266 | expect(loadedClient.provider.url).to.equal(providerUri) 267 | }) 268 | }) 269 | }) 270 | 271 | describe('currentLocation()', () => { 272 | it('should return the current window uri', () => { 273 | localStorage.clear() 274 | 275 | const currentUri = 'https://client-app.example.com' 276 | const auth = new SolidAuthOIDC({ 277 | window: { location: { href: currentUri } }, store: localStorage 278 | }) 279 | 280 | expect(auth.currentLocation()).to.equal(currentUri) 281 | }) 282 | }) 283 | 284 | describe('validateOrSendAuthRequest()', () => { 285 | it('should throw an error when no client is given', () => { 286 | expect(auth.validateOrSendAuthRequest()) 287 | .to.be.rejectedWith(/Could not load or register a RelyingParty client/) 288 | }) 289 | 290 | it('should init user from auth response if present in current uri', () => { 291 | const state = 'abcd' 292 | auth.window.location.href = `https://client-app.example.com#state=${state}` 293 | const aliceWebId = 'https://alice.example.com/' 294 | const initUserFromResponseStub = sinon.stub().resolves(aliceWebId) 295 | auth.initUserFromResponse = initUserFromResponseStub 296 | const mockClient = {} 297 | 298 | return auth.validateOrSendAuthRequest(mockClient) 299 | .then(webId => { 300 | expect(webId).to.equal(aliceWebId) 301 | expect(initUserFromResponseStub).to.have.been.calledWith(mockClient) 302 | }) 303 | }) 304 | 305 | it('should send an auth request if no auth response in current uri', () => { 306 | const sendAuthRequestStub = sinon.stub().resolves(null) 307 | auth.sendAuthRequest = sendAuthRequestStub 308 | const mockClient = {} 309 | 310 | return auth.validateOrSendAuthRequest(mockClient) 311 | .then(() => { 312 | expect(sendAuthRequestStub).to.have.been.calledWith(mockClient) 313 | }) 314 | }) 315 | }) 316 | 317 | describe('initUserFromResponse()', () => { 318 | it('should validate the auth response', () => { 319 | const aliceWebId = 'https://alice.example.com/' 320 | const authResponse = { 321 | authorization: { 322 | id_token: 'sample.id.token', 323 | access_token: 'sample.access.token' 324 | }, 325 | idClaims: { 326 | sub: aliceWebId 327 | } 328 | } 329 | const validateResponseStub = sinon.stub().resolves(authResponse) 330 | const mockClient = { 331 | validateResponse: validateResponseStub 332 | } 333 | 334 | return auth.initUserFromResponse(mockClient) 335 | .then(webId => { 336 | expect(webId).to.equal(aliceWebId) 337 | expect(validateResponseStub).to.have.been.called() 338 | }) 339 | }) 340 | }) 341 | 342 | describe('sendAuthRequest()', () => { 343 | it('should compose an auth request uri, save provider, and redirect', () => { 344 | const state = 'abcd' 345 | const providerUri = 'https://provider.example.com' 346 | const authUri = `https://provider.example.com/authorize?state=${state}` 347 | const createRequestStub = sinon.stub().resolves(authUri) 348 | const mockClient = { 349 | provider: { url: providerUri }, 350 | createRequest: createRequestStub 351 | } 352 | 353 | auth.sendAuthRequest(mockClient) 354 | .then(() => { 355 | // ensure providerUri was saved 356 | expect(auth.loadProvider(state)).to.equal(providerUri) 357 | // ensure the redirect happened 358 | expect(auth.currentLocation()).to.equal(authUri) 359 | }) 360 | }) 361 | }) 362 | 363 | describe('currentUser()', () => { 364 | it('should return cached webId if present', () => { 365 | const aliceWebId = 'https://alice.example.com' 366 | auth.webId = aliceWebId 367 | 368 | expect(auth.currentUser()).to.eventually.equal(aliceWebId) 369 | }) 370 | 371 | it('should return null if no cached webId and no current state param', () => { 372 | expect(auth.currentUser()).to.eventually.not.exist() 373 | }) 374 | 375 | it('should automatically login if current uri has state param', () => { 376 | const state = 'abcd' 377 | const providerUri = 'https://provider.example.com' 378 | auth.saveProviderByState(state, providerUri) 379 | 380 | auth.window.location.href = `https://client-app.example.com#state=${state}` 381 | const aliceWebId = 'https://alice.example.com/' 382 | const loginStub = sinon.stub().resolves(aliceWebId) 383 | auth.login = loginStub 384 | 385 | return auth.currentUser() 386 | .then(webId => { 387 | expect(webId).to.equal(aliceWebId) 388 | expect(loginStub).to.have.been.calledWith(providerUri) 389 | }) 390 | }) 391 | }) 392 | 393 | describe('providerEndSessionEndpoint()', () => { 394 | it('should return null if no current client', () => { 395 | auth.currentClient = null 396 | 397 | const url = auth.providerEndSessionEndpoint() 398 | 399 | expect(url).to.equal(null) 400 | }) 401 | 402 | it('should return null if current client has no provider', () => { 403 | auth.currentClient = {} 404 | 405 | const url = auth.providerEndSessionEndpoint() 406 | 407 | expect(url).to.equal(null) 408 | }) 409 | 410 | it('should return null if current provider has no configuration', () => { 411 | auth.currentClient = { provider: {} } 412 | 413 | const url = auth.providerEndSessionEndpoint() 414 | 415 | expect(url).to.equal(null) 416 | }) 417 | 418 | it('should return null if current configuration has no end session endpoint', () => { 419 | auth.currentClient = { provider: { configuration: {} } } 420 | 421 | const url = auth.providerEndSessionEndpoint() 422 | 423 | expect(url).to.equal(null) 424 | }) 425 | 426 | it('should return the provider end session endpoint', () => { 427 | auth.currentClient = { 428 | provider: { 429 | configuration: { 430 | end_session_endpoint: 'https://example.com/logout' 431 | } 432 | } 433 | } 434 | 435 | const url = auth.providerEndSessionEndpoint() 436 | 437 | expect(url).to.equal('https://example.com/logout') 438 | }) 439 | }) 440 | 441 | describe('clearAuthResponseFromUrl()', () => { 442 | it('should replace the current url with a no-hash cleared one', () => { 443 | const clearedUrl = 'https://rp.com' 444 | 445 | auth.currentLocationNoHash = sinon.stub().returns(clearedUrl) 446 | auth.replaceCurrentUrl = sinon.stub() 447 | 448 | auth.clearAuthResponseFromUrl() 449 | 450 | expect(auth.replaceCurrentUrl).to.have.been.calledWith(clearedUrl) 451 | }) 452 | }) 453 | 454 | describe('replaceCurrentUrl()', () => { 455 | it('should do nothing if no window history present', () => { 456 | const auth = new SolidAuthOIDC() 457 | auth.window = {} 458 | 459 | expect(() => { auth.replaceCurrentUrl() }).to.not.throw() 460 | }) 461 | 462 | it('should invoke replaceState() on window history with new url', () => { 463 | const auth = new SolidAuthOIDC() 464 | auth.window = { 465 | history: { 466 | replaceState: sinon.stub() 467 | } 468 | } 469 | 470 | const clearedUrl = 'https://example.com' 471 | auth.currentLocationNoHash = sinon.stub().returns(clearedUrl) 472 | 473 | auth.replaceCurrentUrl() 474 | }) 475 | }) 476 | 477 | describe('currentLocationNoHash()', () => { 478 | it('should return null if no current location', () => { 479 | const auth = new SolidAuthOIDC() 480 | 481 | const url = auth.currentLocationNoHash() 482 | 483 | expect(url).to.equal(null) 484 | }) 485 | 486 | it('should return the current location with cleared hash fragment', () => { 487 | const auth = new SolidAuthOIDC() 488 | 489 | const currentUrl = 'https://example.com/#whatever' 490 | 491 | auth.currentLocation = sinon.stub().returns(currentUrl) 492 | 493 | const url = auth.currentLocationNoHash() 494 | 495 | expect(url).to.equal('https://example.com/') 496 | }) 497 | }) 498 | 499 | describe('issuePoPTokenFor()', () => { 500 | before(() => { 501 | sinon.stub(PoPToken, 'issueFor').resolves() 502 | }) 503 | 504 | after(() => { 505 | PoPToken.issueFor.restore() 506 | }) 507 | 508 | it('should invoke PoPToken.issueFor', () => { 509 | const auth = new SolidAuthOIDC() 510 | const uri = 'https://rs.com' 511 | const session = {} 512 | 513 | return auth.issuePoPTokenFor(uri, session) 514 | .then(() => { 515 | expect(PoPToken.issueFor).to.have.been.calledWith(uri, session) 516 | }) 517 | }) 518 | }) 519 | }) 520 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | var path = require('path') 2 | 3 | module.exports = { 4 | entry: [ 5 | './lib/index.js' 6 | ], 7 | output: { 8 | path: path.join(__dirname, '/dist/'), 9 | filename: 'solid-auth-oidc.min.js', 10 | library: 'SolidAuthOIDC', 11 | libraryTarget: 'var' 12 | }, 13 | module: { 14 | loaders: [ 15 | { 16 | test: /\.js$/, 17 | loader: 'babel-loader', 18 | exclude: /(node_modules)/, 19 | query: { 20 | presets: ['es2015'] 21 | } 22 | } 23 | ] 24 | }, 25 | externals: { 26 | 'node-fetch': 'fetch', 27 | 'text-encoding': 'TextEncoder', 28 | urlutils: 'URL', 29 | '@trust/webcrypto': 'crypto' 30 | }, 31 | devtool: 'source-map' 32 | } 33 | --------------------------------------------------------------------------------