diff --git a/README.md b/README.md index b13197b..038822f 100644 --- a/README.md +++ b/README.md @@ -7,10 +7,13 @@ This library implements a very simple version of the Solid OIDC protocol: - [x] AuthorizationCodeGrant - [x] with PKCE (RFC 7636) - [x] with `iss` check (RFC 9207) +- [ ] TODO: with provided `client_id` (dereferencable to client profile document) - [x] with dynamic client registration -- [ ] TODO: support provided `client_id` with client profile document -- [x] RefreshTokenGrant to renew a session -- [x] Uses `sessionStorage` to temporarily store session information like `idp`, `client_id`, `refresh_token`, and `token_endpoint`. The storage is origin-bound and tab-bound, meaning that you can have multiple distinct sessions on the same origin using different tabs (see also [security considerations](#security-considerations)). +- [x] RefreshTokenGrant to renew tokens and to restore a session + +Good to know (see also the [security considerations](#security-considerations)): +- [x] Uses [sessionStorage](https://developer.mozilla.org/en-US/docs/Web/API/Window/sessionStorage) in the AuthorizationCodeGrant to temporarily store session information like `idp`, `client_id`, `pkce_code_verifier`, and `csrf_token`. The storage is origin-bound and tab-bound. +- [x] Uses the [IndexedDB API](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API) to store session information like `idp`, `client_id`, `refesh_token`, and the (non-extractable) DPoP KeyPair which was used in the AuthorizationCodeGrant. These are later re-used in the RefreshTokenGrant to renew the tokens or to restore a session. ## Installation You can use this library in your project. Let me know how you get on with it! :rocket: @@ -23,12 +26,12 @@ npm install @uvdsl/solid-oidc-client-browser #### via a CDN provider For the minified version... ```html - + ``` And the regular version... ```html - + ``` Do not forget to adjust the version to the one you want! The latest version is displayed at the top of the README in the `npm` badge. @@ -46,7 +49,7 @@ You can use this library along the lines of these examples: Solid Login Page - + @@ -67,7 +70,7 @@ You can use this library along the lines of these examples: document.addEventListener('DOMContentLoaded', async () => { // Import the Session class from the library - const module = await import('https://unpkg.com/@uvdsl/solid-oidc-client-browser@0.0.8/dist/esm/index.min.js'); + const module = await import('https://unpkg.com/@uvdsl/solid-oidc-client-browser@0.0.9/dist/esm/index.min.js'); const Session = module.Session; // Create a new session @@ -76,7 +79,7 @@ You can use this library along the lines of these examples: // Set up the login button document.getElementById('loginButton').addEventListener('click', () => { // Use a default IDP or let user specify one - const idp = "https://solidcommunity.net"; // Default IDP - you can change this + const idp = "https://solidcommunity.net/"; // Default IDP - you can change this const redirect_uri = window.location.href; // Redirect to login @@ -179,16 +182,26 @@ You can use the `session` object in that store to let the store fetch (authentic ## Security Considerations -For a discussion around security considerations for this library see also [#3](https://github.com/uvdsl/solid-oidc-client-browser/issues/3). We provide a digest here: +For a discussion around security considerations for this library see also the issues: [#3](https://github.com/uvdsl/solid-oidc-client-browser/issues/3) and [#6](https://github.com/uvdsl/solid-oidc-client-browser/issues/6). We provide a digest here: -#### Status Quo: `sessionStorage` +#### Status Quo: `IndexedDB API` -We chose to not use `localStorage` because we did not want to persist the refresh tokens - I ([@uvdsl](https://github.com/uvdsl/)) am unsure if this is really secure. So, we chose to use `sessionStorage`. Yes, if a user closes a tab, they need to login again - but I like that better than potentially leaking a session across tabs or being extracted from `localStorage` after the fact. Again - not sure if this concern is valid - but I feel better this way 😄 +We chose an `IndexedDB` over `localStorage` or `sessionStorage` because: +To renew tokens, the token request (in a RefreshTokenGrant) must contain a DPoP token signed by the same DPoP private key that was used on the initial token request (in the initial AuthorizationCodeGrant) for the session. +To persist this private key, we would need to make it extractable. +This means that if an attacker gains access to `localStorage` or `sessionStorage`, they are able to take the `refresh_token` and the private key, and re-use both outside of the context of the compromised application. -We use `sessionStorage` to store `client_id`, `refresh token` etc. to allow for refreshing tokens using the RefreshTokenFlow. No redirect is needed in that flow, so if a `client_id`, `refresh token` etc. are present in the `sessionStorage`, we simply obtain a fresh set of tokens. Everytime the page is reloaded, the RefreshTokenGrant is executed (as the `sessionStorage` persists until the tab is closed). +We use an `IndexedDB` which allows us to store the non-extractable DPoP KeyPair. This keypair cannot be extracted from the Browser's security context. +This means that, if an attacker gains access to our IndexedDB, they can obtain a fresh set of tokens and thus have successfully established a valid user session (using the DPoP keypair from the `IndexedDB`). +But they do not fully control the DPoP KeyPair. They cannot extract the DPoP KeyPair and send it away. They can only operate within the compromised application. -`sessionStorage` is origin-bound and tab-bound, meaning that you can have multiple distinct sessions on the same origin using different tabs. +#### Why not rely on "Silent Authentication"? +Currently, CSS/Pivot and ESS (afaik) set session cookies with `SameSite=None` +- which in turn allows silent authentication via iframes and popups +- which in turn allows an attacker upon successful JS execution in a compromised application to execute silent authentication in the background without interuption +- which in turn results in a set of tokens bound to an attacker controlled and thus certainly extractable DPoP keypair +- which in turn allows the attacker to re-use the session outside of the compromised application. #### Hosting multiple Solid Apps on the same origin (at different paths) @@ -211,7 +224,10 @@ If you think that the two Solid Apps should still have distinct `client_id`, the To summarise the point: The question on multiple apps on the same origin is to be answered by considering the conceptual relation of the multiple apps with regards to the browers' security mechansims. -We - as in this library - cannot manage distinct sessions within one `sessionStorage` securely. Not because we do not want to but because the browser does not provide us a more granular and secure option. Do you really want distinct logins and distinct sessions? This is not a question of concept but a question of security. You MUST deploy the apps on different origins. +We - as in this library - cannot manage distinct sessions via the `IndexedDB API` securely. Not because we do not want to but because the browser does not provide us a more granular and secure (!) option. Of course, we could provide different databases for different paths on an origin. +But all these databases would still be accessible from any path on the origin. + +Do you really want distinct logins and distinct sessions? This is not a question of concept but a question of security. You MUST deploy the apps on different origins. --- diff --git a/package-lock.json b/package-lock.json index ecc2b33..9cca61f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@uvdsl/solid-oidc-client-browser", - "version": "0.0.8", + "version": "0.0.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@uvdsl/solid-oidc-client-browser", - "version": "0.0.8", + "version": "0.0.9", "license": "MIT", "dependencies": { "jose": "^5.9.6" diff --git a/package.json b/package.json index 6a0d356..25ac65e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@uvdsl/solid-oidc-client-browser", - "version": "0.0.8", + "version": "0.0.9", "homepage": "https://github.com/uvdsl/", "author": "uvdsl - Christoph Braun", "license": "MIT", diff --git a/src/AuthorizationCodeGrantFlow.ts b/src/AuthorizationCodeGrantFlow.ts index 47dd1d8..3a626ab 100644 --- a/src/AuthorizationCodeGrantFlow.ts +++ b/src/AuthorizationCodeGrantFlow.ts @@ -1,6 +1,7 @@ import { createRemoteJWKSet, generateKeyPair, jwtVerify, exportJWK, SignJWT, GenerateKeyPairResult, KeyLike, calculateJwkThumbprint } from "jose"; import { requestDynamicClientRegistration } from "./requestDynamicClientRegistration"; import { SessionTokenInformation } from "./SessionTokenInformation"; +import { SessionDatabase } from "./SessionDatabase"; /** * Login with the idp, using dynamic client registration. @@ -12,7 +13,7 @@ import { SessionTokenInformation } from "./SessionTokenInformation"; const redirectForLogin = async (idp: string, redirect_uri: string) => { // RFC 6749 - Section 3.1.2 - sanitize redirect_uri const redirect_uri_ = new URL(redirect_uri); - const redirect_uri_sane = redirect_uri_.origin + redirect_uri_.pathname + redirect_uri_.search; + const redirect_uri_sane = redirect_uri_.origin + redirect_uri_.pathname + redirect_uri_.search; // RFC 9207 iss check: remember the identity provider (idp) / issuer (iss) sessionStorage.setItem("idp", idp); // lookup openid configuration of idp @@ -48,11 +49,9 @@ const redirectForLogin = async (idp: string, redirect_uri: string) => { return response.json(); }); - // remember client_id and client_secret + // remember client_id const client_id = client_registration["client_id"]; sessionStorage.setItem("client_id", client_id); - const client_secret = client_registration["client_secret"]; - sessionStorage.setItem("client_secret", client_secret); // RFC 7636 PKCE, remember code verifer const { pkce_code_verifier, pkce_code_challenge } = await getPKCEcode(); @@ -143,12 +142,6 @@ const onIncomingRedirect = async () => { "Access Token Request preparation - Could not find in sessionStorage: client_id" ); } - const client_secret = sessionStorage.getItem("client_secret"); - if (client_secret === null) { - throw new Error( - "Access Token Request preparation - Could not find in sessionStorage: client_secret" - ); - } const token_endpoint = sessionStorage.getItem("token_endpoint"); if (token_endpoint === null) { throw new Error( @@ -165,7 +158,6 @@ const onIncomingRedirect = async () => { pkce_code_verifier, url.toString(), client_id, - client_secret, token_endpoint, key_pair ) @@ -205,15 +197,25 @@ const onIncomingRedirect = async () => { } // clean session storage - // sessionStorage.removeItem("idp"); sessionStorage.removeItem("csrf_token"); sessionStorage.removeItem("pkce_code_verifier"); - // sessionStorage.removeItem("client_id"); - // sessionStorage.removeItem("client_secret"); - // sessionStorage.removeItem("token_endpoint"); + sessionStorage.removeItem("idp"); + sessionStorage.removeItem("jwks_uri"); + sessionStorage.removeItem("token_endpoint"); + sessionStorage.removeItem("client_id"); + + // to remember for session restore + const sessionDatabase = await new SessionDatabase().init(); + await Promise.all([ + sessionDatabase.setItem("idp", idp), + sessionDatabase.setItem("jwks_uri", jwks_uri), + sessionDatabase.setItem("token_endpoint", token_endpoint), + sessionDatabase.setItem("client_id", client_id), + sessionDatabase.setItem("dpop_keypair", key_pair), + sessionDatabase.setItem("refresh_token", token_response["refresh_token"]) + ]); + sessionDatabase.close(); - // remember refresh_token for session - sessionStorage.setItem("refresh_token", token_response["refresh_token"]); // return client login information return { @@ -229,7 +231,6 @@ const onIncomingRedirect = async () => { * @param pkce_code_verifier * @param redirect_uri * @param client_id - * @param client_secret * @param token_endpoint * @param key_pair * @returns @@ -239,7 +240,6 @@ const requestAccessToken = async ( pkce_code_verifier: string, redirect_uri: string, client_id: string, - client_secret: string, token_endpoint: string, key_pair: GenerateKeyPairResult ) => { @@ -274,7 +274,6 @@ const requestAccessToken = async ( code_verifier: pkce_code_verifier, redirect_uri: redirect_uri, client_id: client_id, - client_secret: client_secret, }), }); }; diff --git a/src/RefreshTokenGrant.ts b/src/RefreshTokenGrant.ts index 2db4050..8814960 100644 --- a/src/RefreshTokenGrant.ts +++ b/src/RefreshTokenGrant.ts @@ -5,27 +5,28 @@ import { calculateJwkThumbprint, createRemoteJWKSet, exportJWK, - generateKeyPair, jwtVerify, } from "jose"; import { SessionTokenInformation } from "./SessionTokenInformation"; +import { SessionDatabase } from "./SessionDatabase"; const renewTokens = async () => { - const client_id = sessionStorage.getItem("client_id"); - const client_secret = sessionStorage.getItem("client_secret"); - const refresh_token = sessionStorage.getItem("refresh_token"); - const token_endpoint = sessionStorage.getItem("token_endpoint"); - if (!client_id || !client_secret || !refresh_token || !token_endpoint) { + // remember session details + const sessionDatabase = await new SessionDatabase().init(); + const client_id = await sessionDatabase.getItem("client_id") as string; + const token_endpoint = await sessionDatabase.getItem("token_endpoint") as string; + const key_pair = await sessionDatabase.getItem("dpop_keypair") as GenerateKeyPairResult; + const refresh_token = await sessionDatabase.getItem("refresh_token") as string; + + if (client_id === null || token_endpoint === null || key_pair === null || refresh_token === null) { // we can not restore the old session throw new Error("Cannot renew tokens"); } - // RFC 9449 DPoP - const key_pair = await generateKeyPair("ES256"); + const token_response = await requestFreshTokens( refresh_token, client_id, - client_secret, token_endpoint, key_pair ) @@ -38,16 +39,16 @@ const renewTokens = async () => { // verify access_token // ! Solid-OIDC specification says it should be a dpop-bound `id token` but implementations provide a dpop-bound `access token` const accessToken = token_response["access_token"]; - const idp = sessionStorage.getItem("idp"); + const idp = await sessionDatabase.getItem("idp") as string; if (idp === null) { throw new Error( - "Access Token validation preparation - Could not find in sessionStorage: idp" + "Access Token validation preparation - Could not find in sessionDatabase: idp" ); } - const jwks_uri = sessionStorage.getItem("jwks_uri"); + const jwks_uri = await sessionDatabase.getItem("jwks_uri") as string; if (jwks_uri === null) { throw new Error( - "Access Token validation preparation - Could not find in sessionStorage: jwks_uri" + "Access Token validation preparation - Could not find in sessionDatabase: jwks_uri" ); } const jwks = createRemoteJWKSet(new URL(jwks_uri)); @@ -71,7 +72,8 @@ const renewTokens = async () => { } // set new refresh token for token rotation - sessionStorage.setItem("refresh_token",token_response["refresh_token"]); + await sessionDatabase.setItem("refresh_token", token_response["refresh_token"]); + sessionDatabase.close(); return { ...token_response, @@ -85,7 +87,6 @@ const renewTokens = async () => { * @param pkce_code_verifier * @param redirect_uri * @param client_id - * @param client_secret * @param token_endpoint * @param key_pair * @returns @@ -93,7 +94,6 @@ const renewTokens = async () => { const requestFreshTokens = async ( refresh_token: string, client_id: string, - client_secret: string, token_endpoint: string, key_pair: GenerateKeyPairResult ) => { @@ -119,13 +119,13 @@ const requestFreshTokens = async ( { method: "POST", headers: { - authorization: `Basic ${btoa(`${client_id}:${client_secret}`)}`, dpop, "Content-Type": "application/x-www-form-urlencoded", }, body: new URLSearchParams({ grant_type: "refresh_token", - refresh_token: refresh_token, + refresh_token, + client_id }), }); }; diff --git a/src/Session.ts b/src/Session.ts index cac0b94..216a868 100644 --- a/src/Session.ts +++ b/src/Session.ts @@ -5,6 +5,7 @@ import { } from "./AuthorizationCodeGrantFlow"; import { SessionTokenInformation } from "./SessionTokenInformation"; import { renewTokens } from "./RefreshTokenGrant"; +import { SessionDatabase } from "./SessionDatabase"; export class Session { private tokenInformation: SessionTokenInformation | undefined; @@ -13,16 +14,14 @@ export class Session { login = redirectForLogin; - logout() { + async logout() { this.tokenInformation = undefined; this.isActive_ = false; this.webId_ = undefined; - // clean session storage - sessionStorage.removeItem("idp"); - sessionStorage.removeItem("client_id"); - sessionStorage.removeItem("client_secret"); - sessionStorage.removeItem("token_endpoint"); - sessionStorage.removeItem("refresh_token"); + // clean session database + const sessionDatabase = await new SessionDatabase().init() + await sessionDatabase.clear(); + sessionDatabase.close(); } handleRedirectFromLogin() { diff --git a/src/SessionDatabase.ts b/src/SessionDatabase.ts new file mode 100644 index 0000000..f48c64b --- /dev/null +++ b/src/SessionDatabase.ts @@ -0,0 +1,173 @@ +/** + * A simple IndexedDB wrapper + */ +export class SessionDatabase { + private readonly dbName: string; + private readonly storeName: string; + private readonly dbVersion: number; + private db: IDBDatabase | null = null; + + /** + * Creates a new instance + * @param dbName The name of the IndexedDB database + * @param storeName The name of the object store + * @param dbVersion The database version + */ + constructor(dbName: string = 'soidc', storeName: string = 'session', dbVersion: number = 1) { + this.dbName = dbName; + this.storeName = storeName; + this.dbVersion = dbVersion; + } + + /** + * Initializes the IndexedDB database + * @returns Promise that resolves when the database is ready + */ + public async init(): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(this.dbName, this.dbVersion); + + request.onerror = (event) => { + reject(new Error(`Database error: ${(event.target as IDBRequest).error}`)); + }; + + request.onsuccess = (event) => { + this.db = (event.target as IDBOpenDBRequest).result; + resolve(this); + }; + + request.onupgradeneeded = (event) => { + const db = (event.target as IDBOpenDBRequest).result; + + // Check if the object store already exists, if not create it + if (!db.objectStoreNames.contains(this.storeName)) { + db.createObjectStore(this.storeName); + } + }; + }); + } + + /** + * Stores any value in the database with the given ID as key + * @param id The identifier/key for the value + * @param value The value to store + */ + public async setItem(id: string, value: any): Promise { + if (!this.db) { + await this.init(); + } + + return new Promise((resolve, reject) => { + const transaction = this.db!.transaction(this.storeName, 'readwrite'); + // Handle transation + transaction.oncomplete = () => { + resolve(); + }; + transaction.onerror = (event) => { + reject(new Error(`Transaction error for setItem(${id},...): ${(event.target as IDBTransaction).error}`)); + }; + + transaction.onabort = (event) => { + reject(new Error(`Transaction aborted for setItem(${id},...): ${(event.target as IDBTransaction).error}`)); + }; + // Perform the request within the transaction + const store = transaction.objectStore(this.storeName); + store.put(value, id); + }); + } + + /** + * Retrieves a value from the database by ID + * @param id The identifier/key for the value + * @returns The stored value or null if not found + */ + public async getItem(id: string): Promise { + if (!this.db) { + await this.init(); + } + + return new Promise((resolve, reject) => { + const transaction = this.db!.transaction(this.storeName, 'readonly'); + // Handle transation + transaction.onerror = (event) => { + reject(new Error(`Transaction error for getItem(${id}): ${(event.target as IDBTransaction).error}`)); + }; + + transaction.onabort = (event) => { + reject(new Error(`Transaction aborted for getItem(${id}): ${(event.target as IDBTransaction).error}`)); + }; + // Perform the request within the transaction + const store = transaction.objectStore(this.storeName); + const request = store.get(id); + request.onsuccess = () => { + resolve(request.result || null); + }; + }); + } + + /** + * Removes an item from the database + * @param id The identifier of the item to remove + */ + public async deleteItem(id: string): Promise { + if (!this.db) { + await this.init(); + } + + return new Promise((resolve, reject) => { + const transaction = this.db!.transaction(this.storeName, 'readwrite'); + // Handle transation + transaction.oncomplete = () => { + resolve(); + }; + transaction.onerror = (event) => { + reject(new Error(`Transaction error for deleteItem(${id}): ${(event.target as IDBTransaction).error}`)); + }; + + transaction.onabort = (event) => { + reject(new Error(`Transaction aborted for deleteItem(${id}): ${(event.target as IDBTransaction).error}`)); + }; + // Perform the request within the transaction + const store = transaction.objectStore(this.storeName); + store.delete(id); + }); + } + + /** + * Clears all items from the database + */ + public async clear(): Promise { + if (!this.db) { + await this.init(); + } + + return new Promise((resolve, reject) => { + const transaction = this.db!.transaction(this.storeName, 'readwrite'); + // Handle transation + transaction.oncomplete = () => { + resolve(); + }; + transaction.onerror = (event) => { + reject(new Error(`Transaction error for clear(): ${(event.target as IDBTransaction).error}`)); + }; + + transaction.onabort = (event) => { + reject(new Error(`Transaction aborted for clear(): ${(event.target as IDBTransaction).error}`)); + }; + // Perform the request within the transaction + const store = transaction.objectStore(this.storeName); + store.clear(); + }); + } + + /** + * Closes the database connection + */ + public close(): void { + if (this.db) { + this.db.close(); + this.db = null; + } + } + +} \ No newline at end of file diff --git a/src/requestDynamicClientRegistration.ts b/src/requestDynamicClientRegistration.ts index 9fa1206..a0e8545 100644 --- a/src/requestDynamicClientRegistration.ts +++ b/src/requestDynamicClientRegistration.ts @@ -14,7 +14,7 @@ const requestDynamicClientRegistration = async ( redirect_uris: redirect__uris, grant_types: ["authorization_code", "refresh_token"], id_token_signed_response_alg: "ES256", - token_endpoint_auth_method: "client_secret_basic", // also works with value "none" if you do not provide "client_secret" on token request + token_endpoint_auth_method: "none", application_type: "web", subject_type: "public", };