Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,15 @@ All notable changes to this project will be documented in this file.

If you have any questions, see the issues and discussions (e.g. [#18](https://github.com/uvdsl/solid-oidc-client-browser/issues/18))

## [Unreleased]

### ✨ Features (Added)

- **Named sessions**: an optional static `name` in `SessionOptions` lets one app hold several sessions on the same origin (e.g. two Pods side by side). A named session namespaces its pending-login `sessionStorage` keys (`<name>:csrf_token`, ...), gets its own IndexedDB (`soidc:<name>`), and - in the `/web` build - its own `SharedWorker`. The default (unnamed) session is byte-identical to previous releases: same keys, same `soidc` database, same worker URL, so existing persisted sessions survive the upgrade. Named sessions are namespaced for bookkeeping, NOT isolated from each other - the same-origin trust model is unchanged, and they are no substitute for deploying distinct apps on distinct origins.
- **Worker/page storage handshake**: the page sends the database name it expects with `SCHEDULE`/`REFRESH`; a worker bound to a different database answers with an error instead of serving tokens from the wrong session. Named sessions also fetch the worker script with a compat-version query so a stale HTTP-cached pre-namespacing worker can never serve a named session.
- A named session that receives an authorization response while it has no login pending ignores it with a `console.warn`, leaving the response for the session that started the flow. The default session keeps its existing loud error.
- A runnable example, `examples/named-sessions.html`: two independent logins on one page.

## [0.2.3] - 2026-06-18

### 🐛 Fixed
Expand Down
24 changes: 21 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,25 @@ There is a small library that provides [Solid Requests](https://github.com/uvdsl
If you don't want to dabble with parsing the retrieved RDF data manually, check out the [Solid RDF Store](https://github.com/uvdsl/solid-rdf-store).
You can use the `session` object in that store to let the store fetch (authenticated) RDF data from the Web and have reactive query results, i.e. results that can update reactively when query underlying data changes.

#### Multiple sessions in one app (named sessions)

One app may need to hold several user identities at once - say, comparing two Pods side by side.
Give each session a static name:

```ts
const left = new Session({ redirect_uris: [window.location.href], client_name: "My Solid App" }, { name: "left" });
const right = new Session({ redirect_uris: [window.location.href], client_name: "My Solid App" }, { name: "right" });
```

A named session keeps its own login state, its own IndexedDB, and its own refresh worker, so the sessions never interfere with each other's tokens.
On the redirect back from the identity provider, call `handleRedirectFromLogin()` on the session that started the login; a named session with no login pending ignores the redirect (and logs a warning), so the initiating session can consume it.

A runnable example: [`examples/named-sessions.html`](examples/named-sessions.html) — `npm run build`, serve the repo root, open the page, and sign the two panels in.

The name is a static application constant matching `[A-Za-z0-9_-]+` - never derive it from user input.
Named sessions are namespaced for **bookkeeping**, not isolated from each other: every script on the origin can still reach every session's storage, exactly as with a single session.
They are for one app holding several identities - **not** for separating distinct apps on one origin (see the security considerations below).


## Security Considerations

Expand Down Expand Up @@ -121,10 +140,9 @@ 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 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.
We - as in this library - cannot **isolate** distinct sessions via the `IndexedDB API`. Not because we do not want to but because the browser does not provide us a more granular and secure (!) option. Named sessions (see Quick Start) give each session its own database, but all these databases are still 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.
Named sessions are therefore bookkeeping for one app that holds several identities, within one security context - they are no security boundary between apps. Do you really want distinct apps with distinct logins? This is not a question of concept but a question of security. You MUST deploy the apps on different origins.

---

Expand Down
106 changes: 106 additions & 0 deletions examples/named-sessions.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
<!doctype html>
<html lang="en">
<!--
Named sessions: two independent logins on one page.
Run: npm run build, then serve the repo root (e.g. python3 -m http.server 8973)
and open http://localhost:8973/examples/named-sessions.html
-->
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Two sessions, one page — named sessions</title>
<style>
:root { color-scheme: light dark; --line:#d9d4c7; --dim:#45443d; --accent:#6a8a5a; }
@media (prefers-color-scheme: dark) { :root { --dim:#c2c0b4; } }
body { font: 19px/1.6 system-ui, sans-serif; max-width: 60rem; margin: 2rem auto; padding: 0 1.1rem; }
h1 { font-size: 1.7rem; margin: 0 0 .2rem; }
p.lede { color: var(--dim); margin-top: 0; margin-bottom: 1.4rem; }
.sides { display: flex; gap: 1.5rem; flex-wrap: wrap; }
.side { flex: 1 1 22rem; border: 1px solid var(--line); border-radius: 10px; padding: 1rem 1.2rem 1.2rem; }
h2 { font-size: 1.2rem; margin: 0 0 .4rem; }
label { display: block; font-weight: 600; margin: .6rem 0 .2rem; }
input[type=url] { width: 100%; padding: .5rem .6rem; font: inherit; border: 1px solid var(--line);
border-radius: 8px; box-sizing: border-box; background: Field; color: FieldText; }
button { font: inherit; margin: .8rem .5rem 0 0; padding: .45rem .9rem; border-radius: 8px;
border: 1px solid var(--line); cursor: pointer; }
button.login { background: var(--accent); color: #fff; border: none; }
.who { margin: .9rem 0 0; word-break: break-all; }
</style>
</head>
<body>
<h1>Two sessions, one page</h1>
<p class="lede">Each panel is its own named session — <code>left</code> and <code>right</code> —
with its own login, its own storage and its own token refresh. Sign the panels in
with different accounts or providers to hold two identities at once.</p>

<div class="sides">
<section class="side" id="left">
<h2>Left identity</h2>
<label for="left-issuer">Identity provider</label>
<input type="url" id="left-issuer" class="issuer" value="https://solidcommunity.net">
<p>
<button class="login">Sign in</button>
<button class="fetch">Fetch my profile</button>
<button class="logout">Sign out</button>
</p>
<p class="who">Signed out.</p>
</section>

<section class="side" id="right">
<h2>Right identity</h2>
<label for="right-issuer">Identity provider</label>
<input type="url" id="right-issuer" class="issuer" value="https://solidcommunity.net">
<p>
<button class="login">Sign in</button>
<button class="fetch">Fetch my profile</button>
<button class="logout">Sign out</button>
</p>
<p class="who">Signed out.</p>
</section>
</div>

<script type="module">
import { Session } from '../dist/esm/web/index.js';

const REDIRECT = location.origin + location.pathname;

const panel = (name) => {
const root = document.getElementById(name);
const $ = (sel) => root.querySelector(sel);
const session = new Session(
{ redirect_uris: [REDIRECT], client_name: `Named-sessions example (${name})` },
{ name }
);
const show = (text) => {
$('.who').textContent = text
?? (session.isActive ? `Signed in as ${session.webId}` : 'Signed out.');
};
$('.login').onclick = () =>
session.login($('.issuer').value.trim(), REDIRECT)
.catch((e) => show(`sign-in failed to start: ${e.message}`));
$('.logout').onclick = async () => { await session.logout(); show(); };
$('.fetch').onclick = async () => {
if (!session.isActive) { show('Sign in first.'); return; }
const res = await session.authFetch(session.webId, { headers: { accept: 'text/turtle' } })
.catch((e) => ({ status: `failed: ${e.message}` }));
show(`GET profile → HTTP ${res.status}, as ${session.webId}`);
};
return { session, show };
};

const left = panel('left');
const right = panel('right');

// Back from a provider, each named session consumes only its own pending
// login; the other logs a console warning and stays as it was.
try { await left.session.handleRedirectFromLogin(); } catch (e) { left.show(`sign-in failed: ${e.message}`); }
try { await right.session.handleRedirectFromLogin(); } catch (e) { right.show(`sign-in failed: ${e.message}`); }

// A session signed in on an earlier visit comes back from its own storage.
for (const p of [left, right]) {
if (!p.session.isActive) await p.session.restore().catch(() => { /* nothing to restore */ });
p.show();
}
</script>
</body>
</html>
57 changes: 36 additions & 21 deletions src/core/AuthorizationCodeGrant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,17 @@ import { createRemoteJWKSet, generateKeyPair, jwtVerify, exportJWK, SignJWT, Gen
import { requestDynamicClientRegistration } from "./DynamicClientRegistration";
import { ClientDetails, DynamicRegistrationClientDetails, IdentityProviderDetails, SessionInformation, TokenDetails } from "./SessionInformation";
import { SessionDatabase } from "./SessionDatabase";
import { storageKey } from "./SessionName";

/**
* Login with the idp, using a provided `client_id` or dynamic client registration if none provided.
*
* @param idp
* @param redirect_uri
* @param client_details
* @param session_name optional session name namespacing this flow's sessionStorage keys
*/
const redirectForLogin = async (idp: string, redirect_uri: string, client_details?: ClientDetails) => {
const redirectForLogin = async (idp: string, redirect_uri: string, client_details?: ClientDetails, session_name?: 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;
Expand All @@ -31,15 +34,15 @@ const redirectForLogin = async (idp: string, redirect_uri: string, client_detail
"RFC 9207 - iss !== idp - " + issuer + " !== " + idp
);
}
sessionStorage.setItem("idp", issuer);
sessionStorage.setItem(storageKey(session_name, "idp"), issuer);
// remember token endpoint
sessionStorage.setItem(
"token_endpoint",
storageKey(session_name, "token_endpoint"),
openid_configuration["token_endpoint"]
);
// remember jwks_uri for later token verification
sessionStorage.setItem(
"jwks_uri",
storageKey(session_name, "jwks_uri"),
openid_configuration["jwks_uri"]
);

Expand All @@ -66,16 +69,16 @@ const redirectForLogin = async (idp: string, redirect_uri: string, client_detail
try {
new URL(client_id)
} catch {
sessionStorage.setItem("client_id", client_id);
sessionStorage.setItem(storageKey(session_name, "client_id"), client_id);
}

// RFC 7636 PKCE, remember code verifer
const { pkce_code_verifier, pkce_code_challenge } = await getPKCEcode();
sessionStorage.setItem("pkce_code_verifier", pkce_code_verifier);
sessionStorage.setItem(storageKey(session_name, "pkce_code_verifier"), pkce_code_verifier);

// RFC 6749 OAuth 2.0 - CSRF token
const csrf_token = window.crypto.randomUUID();
sessionStorage.setItem("csrf_token", csrf_token);
sessionStorage.setItem(storageKey(session_name, "csrf_token"), csrf_token);

// redirect to idp
const redirect_to_idp =
Expand Down Expand Up @@ -119,25 +122,37 @@ const getPKCEcode = async () => {
* URL contains authrization code, issuer (idp) and state (csrf token),
* get an access token for the authrization code.
*/
const onIncomingRedirect = async (client_details?: ClientDetails, database?: SessionDatabase) => {
const onIncomingRedirect = async (client_details?: ClientDetails, database?: SessionDatabase, session_name?: string) => {
const url = new URL(window.location.href);
// authorization code
const authorization_code = url.searchParams.get("code");
// if no code, session remains unauthenticated at this point
if (authorization_code === null) {
return { clientDetails: client_details } as SessionInformation;
}
// A named session with no pending login (no stored csrf token) does not
// consume the authorization response: with several sessions on one page,
// the response belongs to whichever session started the flow. The URL is
// left untouched so that session can still consume it - but say so, since
// an authorization response that nobody consumes is worth noticing.
// The default (unnamed) session keeps its loud failure below, unchanged.
if (session_name && sessionStorage.getItem(storageKey(session_name, "csrf_token")) === null) {
console.warn(
`solid-oidc-client-browser: session '${session_name}' ignores an authorization response - no login pending`
);
return { clientDetails: client_details } as SessionInformation;
}
// RFC 9207 issuer check
const idp = sessionStorage.getItem("idp");
const idp = sessionStorage.getItem(storageKey(session_name, "idp"));
if (idp === null || url.searchParams.get("iss") !== idp) {
throw new Error(
"RFC 9207 - iss !== idp - " + url.searchParams.get("iss") + " !== " + idp
);
}
// RFC 6749 OAuth 2.0
if (url.searchParams.get("state") !== sessionStorage.getItem("csrf_token")) {
if (url.searchParams.get("state") !== sessionStorage.getItem(storageKey(session_name, "csrf_token"))) {
throw new Error(
"RFC 6749 - state !== csrf_token - " + url.searchParams.get("state") + " !== " + sessionStorage.getItem("csrf_token")
"RFC 6749 - state !== csrf_token - " + url.searchParams.get("state") + " !== " + sessionStorage.getItem(storageKey(session_name, "csrf_token"))
);
}
// remove redirect query parameters from URL
Expand All @@ -147,19 +162,19 @@ const onIncomingRedirect = async (client_details?: ClientDetails, database?: Ses
window.history.pushState({}, document.title, url.toString());

// prepare token request
const pkce_code_verifier = sessionStorage.getItem("pkce_code_verifier");
const pkce_code_verifier = sessionStorage.getItem(storageKey(session_name, "pkce_code_verifier"));
if (pkce_code_verifier === null) {
throw new Error(
"Access Token Request preparation - Could not find in sessionStorage: pkce_code_verifier"
);
}
const client_id = client_details?.client_id || sessionStorage.getItem("client_id");
const client_id = client_details?.client_id || sessionStorage.getItem(storageKey(session_name, "client_id"));
if (!client_id) {
throw new Error(
"Access Token Request preparation - Could not find in sessionStorage: client_id (dynamic registration)"
);
}
const token_endpoint = sessionStorage.getItem("token_endpoint");
const token_endpoint = sessionStorage.getItem(storageKey(session_name, "token_endpoint"));
if (token_endpoint === null) {
throw new Error(
"Access Token Request preparation - Could not find in sessionStorage: token_endpoint"
Expand Down Expand Up @@ -187,7 +202,7 @@ const onIncomingRedirect = async (client_details?: ClientDetails, database?: Ses

// 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 jwks_uri = sessionStorage.getItem("jwks_uri");
const jwks_uri = sessionStorage.getItem(storageKey(session_name, "jwks_uri"));
if (jwks_uri === null) {
throw new Error(
"Access Token validation preparation - Could not find in sessionStorage: jwks_uri"
Expand Down Expand Up @@ -234,12 +249,12 @@ const onIncomingRedirect = async (client_details?: ClientDetails, database?: Ses
}

// clean session storage
sessionStorage.removeItem("csrf_token");
sessionStorage.removeItem("pkce_code_verifier");
sessionStorage.removeItem("idp");
sessionStorage.removeItem("jwks_uri");
sessionStorage.removeItem("token_endpoint");
sessionStorage.removeItem("client_id");
sessionStorage.removeItem(storageKey(session_name, "csrf_token"));
sessionStorage.removeItem(storageKey(session_name, "pkce_code_verifier"));
sessionStorage.removeItem(storageKey(session_name, "idp"));
sessionStorage.removeItem(storageKey(session_name, "jwks_uri"));
sessionStorage.removeItem(storageKey(session_name, "token_endpoint"));
sessionStorage.removeItem(storageKey(session_name, "client_id"));

// return session information
return {
Expand Down
Loading