Skip to content
Merged
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
44 changes: 30 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -23,12 +26,12 @@ npm install @uvdsl/solid-oidc-client-browser
#### via a CDN provider
For the minified version...
```html
<script type="module" src="https://unpkg.com/@uvdsl/[email protected].8/dist/esm/index.min.js"></script>
<script type="module" src="https://unpkg.com/@uvdsl/[email protected].9/dist/esm/index.min.js"></script>
```

And the regular version...
```html
<script type="module" src="https://unpkg.com/@uvdsl/[email protected].8/dist/esm/index.js"></script>
<script type="module" src="https://unpkg.com/@uvdsl/[email protected].9/dist/esm/index.js"></script>
```
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.

Expand All @@ -46,7 +49,7 @@ You can use this library along the lines of these examples:
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Solid Login Page</title>
<script type="module" src="https://unpkg.com/@uvdsl/[email protected].8/dist/esm/index.min.js"></script>
<script type="module" src="https://unpkg.com/@uvdsl/[email protected].9/dist/esm/index.min.js"></script>
</head>

<body>
Expand All @@ -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/[email protected].8/dist/esm/index.min.js');
const module = await import('https://unpkg.com/@uvdsl/[email protected].9/dist/esm/index.min.js');
const Session = module.Session;

// Create a new session
Expand All @@ -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
Expand Down Expand Up @@ -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)

Expand All @@ -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.

---

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
39 changes: 19 additions & 20 deletions src/AuthorizationCodeGrantFlow.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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(
Expand All @@ -165,7 +158,6 @@ const onIncomingRedirect = async () => {
pkce_code_verifier,
url.toString(),
client_id,
client_secret,
token_endpoint,
key_pair
)
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand All @@ -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<KeyLike>
) => {
Expand Down Expand Up @@ -274,7 +274,6 @@ const requestAccessToken = async (
code_verifier: pkce_code_verifier,
redirect_uri: redirect_uri,
client_id: client_id,
client_secret: client_secret,
}),
});
};
Expand Down
36 changes: 18 additions & 18 deletions src/RefreshTokenGrant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<KeyLike>;
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
)
Expand All @@ -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));
Expand All @@ -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,
Expand All @@ -85,15 +87,13 @@ const renewTokens = async () => {
* @param pkce_code_verifier
* @param redirect_uri
* @param client_id
* @param client_secret
* @param token_endpoint
* @param key_pair
* @returns
*/
const requestFreshTokens = async (
refresh_token: string,
client_id: string,
client_secret: string,
token_endpoint: string,
key_pair: GenerateKeyPairResult<KeyLike>
) => {
Expand All @@ -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
}),
});
};
Expand Down
13 changes: 6 additions & 7 deletions src/Session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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() {
Expand Down
Loading