@9in-io/sdk 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +177 -2
- package/dist/index.d.ts +119 -0
- package/dist/index.js +231 -0
- package/dist/server.d.ts +299 -0
- package/dist/server.js +490 -0
- package/dist/util.d.ts +39 -0
- package/dist/util.js +96 -0
- package/package.json +49 -4
package/README.md
CHANGED
|
@@ -1,3 +1,178 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @9in-io/sdk
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Official SDK for [9in](https://9in.io). Zero dependencies; runs in browsers, Node 18+,
|
|
4
|
+
Cloudflare Workers, Deno and Bun.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
npm install @9in-io/sdk
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
## Sign users in (browser)
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
import { NineAuth } from "@9in-io/sdk";
|
|
14
|
+
|
|
15
|
+
const auth = new NineAuth({
|
|
16
|
+
issuer: "https://acme.9in.io", // or your custom domain
|
|
17
|
+
clientId: "app_7f3k2m9q",
|
|
18
|
+
redirectUri: `${location.origin}/callback`,
|
|
19
|
+
audience: "https://api.acme.com", // optional: access tokens for your API
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
await auth.login(); // authorization code + PKCE
|
|
23
|
+
await auth.signup(); // opens the sign-up form (prompt=create)
|
|
24
|
+
|
|
25
|
+
// on /callback
|
|
26
|
+
const { user, returnTo } = await auth.handleRedirect();
|
|
27
|
+
|
|
28
|
+
const token = await auth.getAccessToken(); // refreshed (and rotated) automatically
|
|
29
|
+
const res = await auth.fetch("https://api.acme.com/orders");
|
|
30
|
+
await auth.logout({ returnTo: location.origin });
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Protect your API (server)
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
import { verifyAccessToken } from "@9in-io/sdk/server";
|
|
37
|
+
|
|
38
|
+
const claims = await verifyAccessToken(bearer, {
|
|
39
|
+
issuer: "https://acme.9in.io",
|
|
40
|
+
audience: "https://api.acme.com",
|
|
41
|
+
scope: "orders:read",
|
|
42
|
+
});
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Tokens are verified locally against your tenant's JWKS (cached; refetched once when a
|
|
46
|
+
new key appears after rotation). Every algorithm of the 9in key library is supported:
|
|
47
|
+
RS256/384/512, PS256/384/512, ES256, ES384 and EdDSA through JWKS, and HS256/384/512 when
|
|
48
|
+
your API uses a shared secret (`secret: process.env.NINE_API_SECRET`). The key's own
|
|
49
|
+
algorithm is enforced, and only access tokens (`typ: at+jwt`) are accepted.
|
|
50
|
+
|
|
51
|
+
### Revocation: hybrid verification
|
|
52
|
+
|
|
53
|
+
A local check cannot see revocations: a token revoked through `/oauth/revoke`, a sign-out
|
|
54
|
+
or a blocked user keeps passing until it expires. `createTokenVerifier` closes that gap:
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
import { createTokenVerifier, verifyWebhook } from "@9in-io/sdk/server";
|
|
58
|
+
|
|
59
|
+
const verifier = createTokenVerifier({
|
|
60
|
+
issuer: "https://acme.9in.io",
|
|
61
|
+
audience: "https://api.acme.com",
|
|
62
|
+
clientId: process.env.NINE_CLIENT_ID, // a confidential application
|
|
63
|
+
clientSecret: process.env.NINE_CLIENT_SECRET,
|
|
64
|
+
mode: "hybrid", // default
|
|
65
|
+
cacheTtl: 300, // seconds an answer is trusted without news
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const claims = await verifier.verify(bearer, { scope: "orders:read" });
|
|
69
|
+
|
|
70
|
+
// Webhook endpoint subscribed to token.revoked, session.revoked and user.* events
|
|
71
|
+
const event = await verifyWebhook(process.env.NINE_WEBHOOK_SECRET, request);
|
|
72
|
+
verifier.handleEvent(event);
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
| Mode | What it does |
|
|
76
|
+
|---|---|
|
|
77
|
+
| `local` | Signature and claims only. No calls to 9in. |
|
|
78
|
+
| `hybrid` | Local check, then one introspection per token. The answer is cached until a webhook says otherwise or `cacheTtl` passes; a refusal is cached until the token expires. |
|
|
79
|
+
| `introspect` | Introspection on every call. |
|
|
80
|
+
|
|
81
|
+
Opaque access tokens (`9in_at_…`, enabled per tenant or per API) are always introspected,
|
|
82
|
+
and cached in hybrid mode. The cache is in memory, per process. Each process needs the
|
|
83
|
+
webhook events, or a short `cacheTtl`.
|
|
84
|
+
|
|
85
|
+
## Manage users (server)
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
import { ManagementClient } from "@9in-io/sdk/server";
|
|
89
|
+
|
|
90
|
+
const nine = new ManagementClient({ issuer, clientId, clientSecret }); // app with management scopes
|
|
91
|
+
await nine.createUser({ email: "jane@acme.com", password: "…", emailVerified: true });
|
|
92
|
+
await nine.blockUser(userId);
|
|
93
|
+
await nine.revokeUserSessions(userId);
|
|
94
|
+
|
|
95
|
+
// Single-use sign-in link (magic links, moving a signed-in user from your app to 9in)
|
|
96
|
+
const { url } = await nine.createLoginLink(userId, { returnTo: "https://app.acme.com/callback", ttlSeconds: 600 });
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Keep users in your own database (adapter)
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
import { adapterHandler } from "@9in-io/sdk/server";
|
|
103
|
+
|
|
104
|
+
export default {
|
|
105
|
+
fetch: adapterHandler({
|
|
106
|
+
secret: env.NINE_ADAPTER_SECRET,
|
|
107
|
+
async authenticate({ type, value }, password) {
|
|
108
|
+
const user = await db.findByLogin(type, value);
|
|
109
|
+
if (!user) return "invalid";
|
|
110
|
+
if (user.disabled) return "blocked";
|
|
111
|
+
return (await verify(password, user.hash)) ? toAdapterUser(user) : "invalid";
|
|
112
|
+
},
|
|
113
|
+
async findUser(query) {
|
|
114
|
+
const user = "id" in query ? await db.findById(query.id) : await db.findByLogin(query.identifier.type, query.identifier.value);
|
|
115
|
+
return user ? toAdapterUser(user) : null;
|
|
116
|
+
},
|
|
117
|
+
async onEvent(event) {
|
|
118
|
+
if (event.type === "otp.requested") await sendCode(event.data);
|
|
119
|
+
},
|
|
120
|
+
}),
|
|
121
|
+
};
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Requests and responses are signed with HMAC-SHA256 (`9in-signature`), and each response
|
|
125
|
+
is bound to its request id.
|
|
126
|
+
|
|
127
|
+
## Receive webhooks (server)
|
|
128
|
+
|
|
129
|
+
Add an endpoint under **Advanced → Webhooks** in the console and keep the signing secret it
|
|
130
|
+
shows once. `verifyWebhook` checks the `9in-signature` header against the raw body (and a
|
|
131
|
+
5-minute timestamp tolerance) and returns the event, or throws `NineError` (`invalid_signature`).
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
import { verifyWebhook } from "@9in-io/sdk/server";
|
|
135
|
+
|
|
136
|
+
export default {
|
|
137
|
+
async fetch(request: Request, env: { NINE_WEBHOOK_SECRET: string }) {
|
|
138
|
+
const event = await verifyWebhook(env.NINE_WEBHOOK_SECRET, request);
|
|
139
|
+
// event.id is the same on every retry and redelivery: ignore ones you have seen.
|
|
140
|
+
if (event.type === "user.created") await addToCrm(event.data.user);
|
|
141
|
+
return new Response(null, { status: 204 });
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
It reads the body, so pass `request.clone()` if you need it again. Behind a proxy that
|
|
147
|
+
rewrites the URL, pass the path 9in posted to: `verifyWebhook(secret, request, { path: "/hooks/9in" })`.
|
|
148
|
+
Several secrets (`[newSecret, oldSecret]`) are accepted while you rotate.
|
|
149
|
+
|
|
150
|
+
### Process each event once
|
|
151
|
+
|
|
152
|
+
9in retries a delivery until it gets a 2xx (for about a day), and a retry can arrive while a
|
|
153
|
+
slow first attempt is still running. `processEventOnce` runs your handler once per event id:
|
|
154
|
+
|
|
155
|
+
```ts
|
|
156
|
+
import { processEventOnce, verifyWebhook, type EventStore } from "@9in-io/sdk/server";
|
|
157
|
+
|
|
158
|
+
const event = await verifyWebhook(env.NINE_WEBHOOK_SECRET, request);
|
|
159
|
+
const outcome = await processEventOnce(store, event.id, () => handle(event));
|
|
160
|
+
// processed / duplicate → 204; in_progress → 409 (9in retries later); handler threw → id released, error rethrown (5xx)
|
|
161
|
+
return new Response(null, { status: outcome.status === "in_progress" ? 409 : 204 });
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
`store` is an `EventStore` whose `add` is atomic (a Durable Object, D1/SQL with a unique key,
|
|
165
|
+
Redis `SET NX EX`; not Workers KV). `MemoryEventStore` covers a single process. Processed ids
|
|
166
|
+
are kept for 3 days by default (`ttlSeconds`).
|
|
167
|
+
|
|
168
|
+
## Issuers must use https
|
|
169
|
+
|
|
170
|
+
`NineAuth`, `verifyAccessToken` and `ManagementClient` reject an issuer that is not `https://`
|
|
171
|
+
(`insecure_issuer`), so a typo cannot send tokens or credentials in clear text. Plain http is
|
|
172
|
+
accepted for `localhost`, `*.localhost` and loopback addresses; for other development hosts
|
|
173
|
+
(e.g. `http://auth:8897` in Docker) pass `allowInsecureIssuer: true`.
|
|
174
|
+
|
|
175
|
+
## Errors
|
|
176
|
+
|
|
177
|
+
Every failure is a `NineError` with a `code` (`login_required`, `invalid_token`,
|
|
178
|
+
`insufficient_scope`, `invalid_grant`, `insecure_issuer`, …) and, for HTTP errors, a `status`.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
export { NineError, normalizeIssuer } from "./util.js";
|
|
2
|
+
/**
|
|
3
|
+
* Browser sign-in for single-page apps (also works in React Native / Electron with a
|
|
4
|
+
* custom `storage` and `navigate`). Authorization code flow with PKCE, automatic
|
|
5
|
+
* refresh-token rotation, RP-initiated logout.
|
|
6
|
+
*
|
|
7
|
+
* const auth = new NineAuth({ issuer, clientId, redirectUri });
|
|
8
|
+
* await auth.login(); // redirect to the hosted login
|
|
9
|
+
* await auth.handleRedirect(); // on the callback page
|
|
10
|
+
* const user = await auth.getUser();
|
|
11
|
+
* const token = await auth.getAccessToken();
|
|
12
|
+
*/
|
|
13
|
+
export interface StorageLike {
|
|
14
|
+
getItem(key: string): string | null;
|
|
15
|
+
setItem(key: string, value: string): void;
|
|
16
|
+
removeItem(key: string): void;
|
|
17
|
+
}
|
|
18
|
+
export interface NineAuthOptions {
|
|
19
|
+
/** Your tenant's issuer, e.g. https://acme.9in.io or https://login.acme.com */
|
|
20
|
+
issuer: string;
|
|
21
|
+
clientId: string;
|
|
22
|
+
redirectUri: string;
|
|
23
|
+
/** Default: "openid profile email offline_access". */
|
|
24
|
+
scope?: string;
|
|
25
|
+
/** API identifier to request access tokens for (aud claim). */
|
|
26
|
+
audience?: string;
|
|
27
|
+
/** Where tokens are kept. Default: localStorage (falls back to memory). */
|
|
28
|
+
storage?: StorageLike;
|
|
29
|
+
/** Where the PKCE verifier and state are kept during the redirect. Default: sessionStorage. */
|
|
30
|
+
transientStorage?: StorageLike;
|
|
31
|
+
/** Navigation hook (default: window.location.assign). */
|
|
32
|
+
navigate?: (url: string) => void;
|
|
33
|
+
/** Custom fetch (tests, React Native). */
|
|
34
|
+
fetch?: typeof fetch;
|
|
35
|
+
/** Language of the hosted pages (ui_locales). */
|
|
36
|
+
locale?: string;
|
|
37
|
+
/** Accept a plain-http issuer other than localhost (development only). */
|
|
38
|
+
allowInsecureIssuer?: boolean;
|
|
39
|
+
}
|
|
40
|
+
export interface LoginOptions {
|
|
41
|
+
/** "login" forces re-authentication, "consent" re-asks for consent, "none" fails instead of showing UI. */
|
|
42
|
+
prompt?: "login" | "consent" | "none" | "select_account";
|
|
43
|
+
/** Pre-fill the identifier field. */
|
|
44
|
+
loginHint?: string;
|
|
45
|
+
/** Open the sign-up form directly (prompt=create). */
|
|
46
|
+
screen?: "login" | "signup";
|
|
47
|
+
/** Where to send the user after handleRedirect (returned to you). */
|
|
48
|
+
returnTo?: string;
|
|
49
|
+
/** Extra authorization parameters. */
|
|
50
|
+
params?: Record<string, string>;
|
|
51
|
+
}
|
|
52
|
+
export interface TokenSet {
|
|
53
|
+
accessToken: string;
|
|
54
|
+
idToken: string | null;
|
|
55
|
+
refreshToken: string | null;
|
|
56
|
+
/** ms since epoch */
|
|
57
|
+
expiresAt: number;
|
|
58
|
+
scope: string;
|
|
59
|
+
}
|
|
60
|
+
export interface User {
|
|
61
|
+
sub: string;
|
|
62
|
+
email?: string;
|
|
63
|
+
email_verified?: boolean;
|
|
64
|
+
name?: string;
|
|
65
|
+
given_name?: string;
|
|
66
|
+
family_name?: string;
|
|
67
|
+
picture?: string;
|
|
68
|
+
phone_number?: string;
|
|
69
|
+
locale?: string;
|
|
70
|
+
[claim: string]: unknown;
|
|
71
|
+
}
|
|
72
|
+
export declare class NineAuth {
|
|
73
|
+
private readonly options;
|
|
74
|
+
private readonly issuer;
|
|
75
|
+
private readonly storage;
|
|
76
|
+
private readonly transient;
|
|
77
|
+
private readonly fetcher;
|
|
78
|
+
private readonly key;
|
|
79
|
+
private refreshing;
|
|
80
|
+
constructor(options: NineAuthOptions);
|
|
81
|
+
private navigate;
|
|
82
|
+
/** Builds the authorization URL and stores the PKCE verifier; `login()` navigates to it. */
|
|
83
|
+
buildLoginUrl(options?: LoginOptions): Promise<string>;
|
|
84
|
+
/** Redirects to the hosted login page. */
|
|
85
|
+
login(options?: LoginOptions): Promise<void>;
|
|
86
|
+
/** Opens the sign-up form directly. */
|
|
87
|
+
signup(options?: Omit<LoginOptions, "screen">): Promise<void>;
|
|
88
|
+
/**
|
|
89
|
+
* Completes sign-in on the callback page: checks state, exchanges the code with the PKCE
|
|
90
|
+
* verifier and stores the tokens. Removes the code from the address bar.
|
|
91
|
+
*/
|
|
92
|
+
handleRedirect(url?: string): Promise<{
|
|
93
|
+
user: User;
|
|
94
|
+
returnTo?: string;
|
|
95
|
+
}>;
|
|
96
|
+
private sameIssuer;
|
|
97
|
+
private store;
|
|
98
|
+
/** Stored tokens (possibly expired). */
|
|
99
|
+
getTokens(): TokenSet | null;
|
|
100
|
+
/** True when there is a usable access token or a refresh token. */
|
|
101
|
+
isAuthenticated(): boolean;
|
|
102
|
+
/** Claims of the signed-in user from the ID token, or null. */
|
|
103
|
+
getUser(): Promise<User | null>;
|
|
104
|
+
/** Fresh claims from the userinfo endpoint. */
|
|
105
|
+
fetchUserInfo(): Promise<User>;
|
|
106
|
+
/**
|
|
107
|
+
* A valid access token, refreshed (with rotation) when it expires within a minute.
|
|
108
|
+
* Concurrent callers share one refresh. Throws `login_required` when signed out.
|
|
109
|
+
*/
|
|
110
|
+
getAccessToken(): Promise<string>;
|
|
111
|
+
private refresh;
|
|
112
|
+
/** fetch() with the access token attached. */
|
|
113
|
+
fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
|
|
114
|
+
/** Clears local tokens, revokes the refresh token and ends the 9in session. */
|
|
115
|
+
logout(options?: {
|
|
116
|
+
returnTo?: string;
|
|
117
|
+
local?: boolean;
|
|
118
|
+
}): Promise<void>;
|
|
119
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { NineError, base64UrlEncode, decodeJwt, discover, normalizeIssuer, postForm, randomString, sha256 } from "./util.js";
|
|
2
|
+
export { NineError, normalizeIssuer } from "./util.js";
|
|
3
|
+
class MemoryStorage {
|
|
4
|
+
map = new Map();
|
|
5
|
+
getItem(key) {
|
|
6
|
+
return this.map.get(key) ?? null;
|
|
7
|
+
}
|
|
8
|
+
setItem(key, value) {
|
|
9
|
+
this.map.set(key, value);
|
|
10
|
+
}
|
|
11
|
+
removeItem(key) {
|
|
12
|
+
this.map.delete(key);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
function defaultStorage(kind) {
|
|
16
|
+
try {
|
|
17
|
+
const store = globalThis[kind];
|
|
18
|
+
if (store) {
|
|
19
|
+
store.setItem("__9in", "1");
|
|
20
|
+
store.removeItem("__9in");
|
|
21
|
+
return store;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
// storage disabled (private mode, sandboxed iframe)
|
|
26
|
+
}
|
|
27
|
+
return new MemoryStorage();
|
|
28
|
+
}
|
|
29
|
+
export class NineAuth {
|
|
30
|
+
options;
|
|
31
|
+
issuer;
|
|
32
|
+
storage;
|
|
33
|
+
transient;
|
|
34
|
+
fetcher;
|
|
35
|
+
key;
|
|
36
|
+
refreshing = null;
|
|
37
|
+
constructor(options) {
|
|
38
|
+
this.options = options;
|
|
39
|
+
if (!options.issuer || !options.clientId || !options.redirectUri)
|
|
40
|
+
throw new NineError("invalid_options", "issuer, clientId and redirectUri are required");
|
|
41
|
+
this.issuer = normalizeIssuer(options.issuer, options.allowInsecureIssuer);
|
|
42
|
+
this.storage = options.storage ?? defaultStorage("localStorage");
|
|
43
|
+
this.transient = options.transientStorage ?? defaultStorage("sessionStorage");
|
|
44
|
+
this.fetcher = options.fetch ?? fetch.bind(globalThis);
|
|
45
|
+
this.key = `9in:${this.issuer}:${options.clientId}`;
|
|
46
|
+
}
|
|
47
|
+
navigate(url) {
|
|
48
|
+
if (this.options.navigate)
|
|
49
|
+
this.options.navigate(url);
|
|
50
|
+
else
|
|
51
|
+
window.location.assign(url);
|
|
52
|
+
}
|
|
53
|
+
/** Builds the authorization URL and stores the PKCE verifier; `login()` navigates to it. */
|
|
54
|
+
async buildLoginUrl(options = {}) {
|
|
55
|
+
const config = await discover(this.issuer, this.fetcher);
|
|
56
|
+
const pending = { state: randomString(16), nonce: randomString(16), verifier: randomString(48), returnTo: options.returnTo };
|
|
57
|
+
this.transient.setItem(`${this.key}:pending`, JSON.stringify(pending));
|
|
58
|
+
const url = new URL(config.authorization_endpoint);
|
|
59
|
+
const params = {
|
|
60
|
+
response_type: "code",
|
|
61
|
+
client_id: this.options.clientId,
|
|
62
|
+
redirect_uri: this.options.redirectUri,
|
|
63
|
+
scope: this.options.scope ?? "openid profile email offline_access",
|
|
64
|
+
state: pending.state,
|
|
65
|
+
nonce: pending.nonce,
|
|
66
|
+
code_challenge: base64UrlEncode(await sha256(pending.verifier)),
|
|
67
|
+
code_challenge_method: "S256",
|
|
68
|
+
...(this.options.audience ? { audience: this.options.audience } : {}),
|
|
69
|
+
...(this.options.locale ? { ui_locales: this.options.locale } : {}),
|
|
70
|
+
...(options.loginHint ? { login_hint: options.loginHint } : {}),
|
|
71
|
+
...(options.screen === "signup" ? { prompt: "create" } : options.prompt ? { prompt: options.prompt } : {}),
|
|
72
|
+
...options.params,
|
|
73
|
+
};
|
|
74
|
+
for (const [k, v] of Object.entries(params))
|
|
75
|
+
url.searchParams.set(k, v);
|
|
76
|
+
return url.toString();
|
|
77
|
+
}
|
|
78
|
+
/** Redirects to the hosted login page. */
|
|
79
|
+
async login(options = {}) {
|
|
80
|
+
this.navigate(await this.buildLoginUrl(options));
|
|
81
|
+
}
|
|
82
|
+
/** Opens the sign-up form directly. */
|
|
83
|
+
async signup(options = {}) {
|
|
84
|
+
return this.login({ ...options, screen: "signup" });
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Completes sign-in on the callback page: checks state, exchanges the code with the PKCE
|
|
88
|
+
* verifier and stores the tokens. Removes the code from the address bar.
|
|
89
|
+
*/
|
|
90
|
+
async handleRedirect(url = typeof window !== "undefined" ? window.location.href : "") {
|
|
91
|
+
const current = new URL(url);
|
|
92
|
+
const params = current.searchParams;
|
|
93
|
+
const raw = this.transient.getItem(`${this.key}:pending`);
|
|
94
|
+
this.transient.removeItem(`${this.key}:pending`);
|
|
95
|
+
if (params.get("error"))
|
|
96
|
+
throw new NineError(params.get("error"), params.get("error_description") ?? undefined);
|
|
97
|
+
const code = params.get("code");
|
|
98
|
+
if (!code)
|
|
99
|
+
throw new NineError("missing_code", "no authorization code in the URL");
|
|
100
|
+
if (!raw)
|
|
101
|
+
throw new NineError("invalid_state", "no login in progress (opened in another tab?)");
|
|
102
|
+
const pending = JSON.parse(raw);
|
|
103
|
+
if (params.get("state") !== pending.state)
|
|
104
|
+
throw new NineError("invalid_state", "state mismatch");
|
|
105
|
+
const iss = params.get("iss");
|
|
106
|
+
if (iss && iss.replace(/\/$/, "") !== this.issuer && !(await this.sameIssuer(iss)))
|
|
107
|
+
throw new NineError("invalid_issuer", `unexpected issuer ${iss}`);
|
|
108
|
+
const config = await discover(this.issuer, this.fetcher);
|
|
109
|
+
const tokens = await postForm(config.token_endpoint, { grant_type: "authorization_code", code, redirect_uri: this.options.redirectUri, client_id: this.options.clientId, code_verifier: pending.verifier }, {}, this.fetcher);
|
|
110
|
+
const set = this.store(tokens);
|
|
111
|
+
if (set.idToken) {
|
|
112
|
+
// The ID token came straight from the token endpoint over TLS (OIDC Core 3.1.3.7):
|
|
113
|
+
// its claims are checked; the signature check is for servers (verifyAccessToken).
|
|
114
|
+
const { payload } = decodeJwt(set.idToken);
|
|
115
|
+
const aud = Array.isArray(payload.aud) ? payload.aud : [payload.aud];
|
|
116
|
+
if (!aud.includes(this.options.clientId))
|
|
117
|
+
throw new NineError("invalid_id_token", "audience mismatch");
|
|
118
|
+
if (payload.nonce !== pending.nonce)
|
|
119
|
+
throw new NineError("invalid_id_token", "nonce mismatch");
|
|
120
|
+
}
|
|
121
|
+
if (typeof window !== "undefined" && window.history && url === window.location.href) {
|
|
122
|
+
for (const p of ["code", "state", "iss", "session_state"])
|
|
123
|
+
current.searchParams.delete(p);
|
|
124
|
+
window.history.replaceState(window.history.state, "", current.pathname + current.search + current.hash);
|
|
125
|
+
}
|
|
126
|
+
return { user: (await this.getUser()), returnTo: pending.returnTo };
|
|
127
|
+
}
|
|
128
|
+
async sameIssuer(iss) {
|
|
129
|
+
const config = await discover(this.issuer, this.fetcher);
|
|
130
|
+
return config.issuer.replace(/\/$/, "") === iss.replace(/\/$/, "");
|
|
131
|
+
}
|
|
132
|
+
store(tokens, previous) {
|
|
133
|
+
const set = {
|
|
134
|
+
accessToken: String(tokens.access_token),
|
|
135
|
+
idToken: typeof tokens.id_token === "string" ? tokens.id_token : (previous?.idToken ?? null),
|
|
136
|
+
refreshToken: typeof tokens.refresh_token === "string" ? tokens.refresh_token : (previous?.refreshToken ?? null),
|
|
137
|
+
expiresAt: Date.now() + Number(tokens.expires_in ?? 3600) * 1000,
|
|
138
|
+
scope: String(tokens.scope ?? previous?.scope ?? ""),
|
|
139
|
+
};
|
|
140
|
+
this.storage.setItem(`${this.key}:tokens`, JSON.stringify(set));
|
|
141
|
+
return set;
|
|
142
|
+
}
|
|
143
|
+
/** Stored tokens (possibly expired). */
|
|
144
|
+
getTokens() {
|
|
145
|
+
const raw = this.storage.getItem(`${this.key}:tokens`);
|
|
146
|
+
if (!raw)
|
|
147
|
+
return null;
|
|
148
|
+
try {
|
|
149
|
+
return JSON.parse(raw);
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/** True when there is a usable access token or a refresh token. */
|
|
156
|
+
isAuthenticated() {
|
|
157
|
+
const t = this.getTokens();
|
|
158
|
+
return !!t && (t.expiresAt > Date.now() || !!t.refreshToken);
|
|
159
|
+
}
|
|
160
|
+
/** Claims of the signed-in user from the ID token, or null. */
|
|
161
|
+
async getUser() {
|
|
162
|
+
const t = this.getTokens();
|
|
163
|
+
if (!t?.idToken)
|
|
164
|
+
return null;
|
|
165
|
+
return decodeJwt(t.idToken).payload;
|
|
166
|
+
}
|
|
167
|
+
/** Fresh claims from the userinfo endpoint. */
|
|
168
|
+
async fetchUserInfo() {
|
|
169
|
+
const config = await discover(this.issuer, this.fetcher);
|
|
170
|
+
const res = await this.fetcher(config.userinfo_endpoint, { headers: { Authorization: `Bearer ${await this.getAccessToken()}` } });
|
|
171
|
+
if (!res.ok)
|
|
172
|
+
throw new NineError("userinfo_failed", `HTTP ${res.status}`, res.status);
|
|
173
|
+
return (await res.json());
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* A valid access token, refreshed (with rotation) when it expires within a minute.
|
|
177
|
+
* Concurrent callers share one refresh. Throws `login_required` when signed out.
|
|
178
|
+
*/
|
|
179
|
+
async getAccessToken() {
|
|
180
|
+
const t = this.getTokens();
|
|
181
|
+
if (t && t.expiresAt - 60_000 > Date.now())
|
|
182
|
+
return t.accessToken;
|
|
183
|
+
if (!t?.refreshToken)
|
|
184
|
+
throw new NineError("login_required");
|
|
185
|
+
this.refreshing ??= this.refresh(t).finally(() => {
|
|
186
|
+
this.refreshing = null;
|
|
187
|
+
});
|
|
188
|
+
const next = await this.refreshing;
|
|
189
|
+
if (!next)
|
|
190
|
+
throw new NineError("login_required");
|
|
191
|
+
return next.accessToken;
|
|
192
|
+
}
|
|
193
|
+
async refresh(current) {
|
|
194
|
+
const config = await discover(this.issuer, this.fetcher);
|
|
195
|
+
try {
|
|
196
|
+
const tokens = await postForm(config.token_endpoint, { grant_type: "refresh_token", refresh_token: current.refreshToken, client_id: this.options.clientId }, {}, this.fetcher);
|
|
197
|
+
return this.store(tokens, current);
|
|
198
|
+
}
|
|
199
|
+
catch (error) {
|
|
200
|
+
if (error instanceof NineError && error.code === "invalid_grant") {
|
|
201
|
+
this.storage.removeItem(`${this.key}:tokens`);
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
throw error;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
/** fetch() with the access token attached. */
|
|
208
|
+
async fetch(input, init = {}) {
|
|
209
|
+
const headers = new Headers(init.headers);
|
|
210
|
+
headers.set("Authorization", `Bearer ${await this.getAccessToken()}`);
|
|
211
|
+
return this.fetcher(input, { ...init, headers });
|
|
212
|
+
}
|
|
213
|
+
/** Clears local tokens, revokes the refresh token and ends the 9in session. */
|
|
214
|
+
async logout(options = {}) {
|
|
215
|
+
const t = this.getTokens();
|
|
216
|
+
this.storage.removeItem(`${this.key}:tokens`);
|
|
217
|
+
const config = await discover(this.issuer, this.fetcher);
|
|
218
|
+
if (t?.refreshToken && config.revocation_endpoint) {
|
|
219
|
+
await postForm(config.revocation_endpoint, { token: t.refreshToken, token_type_hint: "refresh_token", client_id: this.options.clientId }, {}, this.fetcher).catch(() => undefined);
|
|
220
|
+
}
|
|
221
|
+
if (options.local)
|
|
222
|
+
return;
|
|
223
|
+
const url = new URL(config.end_session_endpoint ?? `${this.issuer}/oauth/logout`);
|
|
224
|
+
url.searchParams.set("client_id", this.options.clientId);
|
|
225
|
+
if (t?.idToken)
|
|
226
|
+
url.searchParams.set("id_token_hint", t.idToken);
|
|
227
|
+
if (options.returnTo)
|
|
228
|
+
url.searchParams.set("post_logout_redirect_uri", options.returnTo);
|
|
229
|
+
this.navigate(url.toString());
|
|
230
|
+
}
|
|
231
|
+
}
|