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",
};