@9in-io/sdk 0.2.0 → 0.3.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/CHANGELOG.md +9 -0
- package/README.md +28 -0
- package/dist/server.d.ts +38 -0
- package/dist/server.js +52 -0
- package/dist/web-app.d.ts +96 -0
- package/dist/web-app.js +138 -0
- package/package.json +2 -2
- package/src/server.ts +75 -0
- package/src/web-app.ts +207 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.0
|
|
4
|
+
|
|
5
|
+
- `WebAppAuth` (`@9in-io/sdk/server`): sign-in for server-rendered apps (authorization code +
|
|
6
|
+
PKCE, with or without a client secret), using only `fetch` and Web Crypto, so it runs on Node,
|
|
7
|
+
Next.js Edge, Cloudflare Workers, Deno and Bun. `createLogin`, `handleCallback` (state, RFC 9207
|
|
8
|
+
issuer check and ID token verification), `refresh`, `logoutUrl`, `revoke`, `userInfo`. It's the
|
|
9
|
+
base of `@9in-io/nextjs`; you keep the transaction and session where you like.
|
|
10
|
+
- `verifyIdToken`: signature, issuer, audience (`azp` with several), expiry and nonce.
|
|
11
|
+
|
|
3
12
|
## 0.2.0
|
|
4
13
|
|
|
5
14
|
Breaking: 9in's wire formats are now snake_case throughout, with ISO 8601 UTC times
|
package/README.md
CHANGED
|
@@ -30,6 +30,34 @@ const res = await auth.fetch("https://api.acme.com/orders");
|
|
|
30
30
|
await auth.logout({ returnTo: location.origin });
|
|
31
31
|
```
|
|
32
32
|
|
|
33
|
+
## Sign users in (server-rendered apps)
|
|
34
|
+
|
|
35
|
+
`WebAppAuth` runs the sign-in on your server: Node, Next.js (also Edge), Cloudflare Workers,
|
|
36
|
+
Deno and Bun. You keep the transaction (between the redirect and the callback) and the session,
|
|
37
|
+
for example in encrypted cookies. With Next.js, `@9in-io/nextjs` does all of that for you.
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
import { WebAppAuth } from "@9in-io/sdk/server";
|
|
41
|
+
|
|
42
|
+
const auth = new WebAppAuth({
|
|
43
|
+
issuer: "https://acme.9in.io",
|
|
44
|
+
clientId: process.env.NINE_CLIENT_ID!,
|
|
45
|
+
clientSecret: process.env.NINE_CLIENT_SECRET, // confidential apps; leave out for public ones
|
|
46
|
+
redirectUri: "https://app.acme.com/auth/callback",
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// /auth/login
|
|
50
|
+
const { url, transaction } = await auth.createLogin({ returnTo: "/dashboard" });
|
|
51
|
+
// store `transaction` (encrypted, ~15 minutes), then redirect to `url`
|
|
52
|
+
|
|
53
|
+
// /auth/callback
|
|
54
|
+
const { tokens, claims, returnTo } = await auth.handleCallback(request.url, transaction);
|
|
55
|
+
// claims: the verified ID token (sub, email, name, amr…); tokens: access, refresh, ID token
|
|
56
|
+
|
|
57
|
+
const fresh = await auth.refresh(tokens.refreshToken!); // 9in rotates refresh tokens: keep the new one
|
|
58
|
+
const bye = await auth.logoutUrl({ idTokenHint: tokens.idToken, postLogoutRedirectUri: "https://app.acme.com/" });
|
|
59
|
+
```
|
|
60
|
+
|
|
33
61
|
## Protect your API (server)
|
|
34
62
|
|
|
35
63
|
```ts
|
package/dist/server.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { NineError, normalizeIssuer } from "./util.js";
|
|
2
|
+
export { WebAppAuth, type WebAppAuthOptions, type WebLoginOptions, type LoginTransaction, type WebTokenSet, type CallbackResult } from "./web-app.js";
|
|
2
3
|
export interface VerifyOptions {
|
|
3
4
|
/** Your tenant's issuer (exactly as in the token's iss claim). */
|
|
4
5
|
issuer: string;
|
|
@@ -39,6 +40,43 @@ export interface AccessTokenClaims {
|
|
|
39
40
|
* `createTokenVerifier` (hybrid mode) when revocation must take effect at once.
|
|
40
41
|
*/
|
|
41
42
|
export declare function verifyAccessToken(token: string | null | undefined, options: VerifyOptions): Promise<AccessTokenClaims>;
|
|
43
|
+
export interface VerifyIdTokenOptions {
|
|
44
|
+
/** Your tenant's issuer URL. */
|
|
45
|
+
issuer: string;
|
|
46
|
+
/** Your application's client ID (the token's audience). */
|
|
47
|
+
clientId: string;
|
|
48
|
+
/** The nonce sent with the sign-in request. */
|
|
49
|
+
nonce?: string | null;
|
|
50
|
+
/** For tenants whose signing key is a shared (HMAC) secret. */
|
|
51
|
+
secret?: string;
|
|
52
|
+
/** Seconds of clock skew allowed (default 30). */
|
|
53
|
+
clockTolerance?: number;
|
|
54
|
+
fetch?: typeof fetch;
|
|
55
|
+
allowInsecureIssuer?: boolean;
|
|
56
|
+
}
|
|
57
|
+
/** Claims of a verified ID token (standard OIDC claims plus your custom profile claims). */
|
|
58
|
+
export interface IdTokenClaims {
|
|
59
|
+
iss: string;
|
|
60
|
+
sub: string;
|
|
61
|
+
aud: string | string[];
|
|
62
|
+
exp: number;
|
|
63
|
+
iat: number;
|
|
64
|
+
nonce?: string;
|
|
65
|
+
auth_time?: number;
|
|
66
|
+
/** How the user signed in, e.g. ["pwd", "mfa", "otp"]. */
|
|
67
|
+
amr?: string[];
|
|
68
|
+
sid?: string;
|
|
69
|
+
email?: string;
|
|
70
|
+
email_verified?: boolean;
|
|
71
|
+
name?: string;
|
|
72
|
+
picture?: string;
|
|
73
|
+
[claim: string]: unknown;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Verifies an ID token from your tenant: signature (JWKS, or the shared secret for HMAC keys),
|
|
77
|
+
* issuer, audience (your client ID; `azp` when there are several), expiry and nonce.
|
|
78
|
+
*/
|
|
79
|
+
export declare function verifyIdToken(token: string | null | undefined, options: VerifyIdTokenOptions): Promise<IdTokenClaims>;
|
|
42
80
|
export type VerificationMode = "local" | "hybrid" | "introspect";
|
|
43
81
|
export interface TokenVerifierOptions extends Omit<VerifyOptions, "scope"> {
|
|
44
82
|
/**
|
package/dist/server.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { NineError, decodeJwt, discover, normalizeIssuer, postForm, toHex, utf8 } from "./util.js";
|
|
2
2
|
export { NineError, normalizeIssuer } from "./util.js";
|
|
3
|
+
export { WebAppAuth } from "./web-app.js";
|
|
3
4
|
/** WebCrypto parameters for each JWS algorithm 9in signs with. */
|
|
4
5
|
const ALGORITHMS = {
|
|
5
6
|
RS256: { import: { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, verify: "RSASSA-PKCS1-v1_5" },
|
|
@@ -111,6 +112,57 @@ export async function verifyAccessToken(token, options) {
|
|
|
111
112
|
checkClaims(payload, issuer, options);
|
|
112
113
|
return payload;
|
|
113
114
|
}
|
|
115
|
+
/**
|
|
116
|
+
* Verifies an ID token from your tenant: signature (JWKS, or the shared secret for HMAC keys),
|
|
117
|
+
* issuer, audience (your client ID; `azp` when there are several), expiry and nonce.
|
|
118
|
+
*/
|
|
119
|
+
export async function verifyIdToken(token, options) {
|
|
120
|
+
if (!token)
|
|
121
|
+
throw new NineError("invalid_token", "missing ID token", 401);
|
|
122
|
+
const fetcher = options.fetch ?? fetch.bind(globalThis);
|
|
123
|
+
const issuer = normalizeIssuer(options.issuer, options.allowInsecureIssuer);
|
|
124
|
+
let parts;
|
|
125
|
+
try {
|
|
126
|
+
parts = decodeJwt(token);
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
throw new NineError("invalid_token", "malformed ID token", 401);
|
|
130
|
+
}
|
|
131
|
+
const { header, payload, signingInput, signature } = parts;
|
|
132
|
+
const alg = String(header.alg);
|
|
133
|
+
// An access token is never accepted as an ID token.
|
|
134
|
+
if (ACCESS_TOKEN_TYPES.has(String(header.typ ?? "").toLowerCase()))
|
|
135
|
+
throw new NineError("invalid_token", "not an ID token", 401);
|
|
136
|
+
let valid;
|
|
137
|
+
if (HMAC_HASH[alg]) {
|
|
138
|
+
if (!options.secret)
|
|
139
|
+
throw new NineError("invalid_token", "HMAC-signed ID token: pass the tenant's shared secret", 401);
|
|
140
|
+
const key = await crypto.subtle.importKey("raw", utf8(options.secret), { name: "HMAC", hash: HMAC_HASH[alg] }, false, ["verify"]);
|
|
141
|
+
valid = await crypto.subtle.verify("HMAC", key, signature, utf8(signingInput));
|
|
142
|
+
}
|
|
143
|
+
else if (ALGORITHMS[alg]) {
|
|
144
|
+
const jwk = await findKey(issuer, header.kid, fetcher);
|
|
145
|
+
if (!jwk)
|
|
146
|
+
throw new NineError("invalid_token", "unknown signing key", 401);
|
|
147
|
+
if (jwk.alg && jwk.alg !== alg)
|
|
148
|
+
throw new NineError("invalid_token", "algorithm does not match the key", 401);
|
|
149
|
+
valid = await crypto.subtle.verify(ALGORITHMS[alg].verify, await importPublicKey(jwk, alg), signature, utf8(signingInput));
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
throw new NineError("invalid_token", "unsupported algorithm", 401);
|
|
153
|
+
}
|
|
154
|
+
if (!valid)
|
|
155
|
+
throw new NineError("invalid_token", "bad signature", 401);
|
|
156
|
+
checkClaims(payload, issuer, { issuer, audience: options.clientId, clockTolerance: options.clockTolerance });
|
|
157
|
+
const aud = Array.isArray(payload.aud) ? payload.aud : [payload.aud];
|
|
158
|
+
if (aud.length > 1 && payload.azp !== options.clientId)
|
|
159
|
+
throw new NineError("invalid_token", "authorized party mismatch", 401);
|
|
160
|
+
if (options.nonce !== undefined && options.nonce !== null && payload.nonce !== options.nonce)
|
|
161
|
+
throw new NineError("invalid_token", "nonce mismatch", 401);
|
|
162
|
+
if (typeof payload.sub !== "string" || !payload.sub)
|
|
163
|
+
throw new NineError("invalid_token", "missing subject", 401);
|
|
164
|
+
return payload;
|
|
165
|
+
}
|
|
114
166
|
function checkClaims(payload, issuer, options) {
|
|
115
167
|
const now = Math.floor(Date.now() / 1000);
|
|
116
168
|
const skew = options.clockTolerance ?? 30;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { type IdTokenClaims } from "./server.js";
|
|
2
|
+
/**
|
|
3
|
+
* Sign-in for server-rendered web apps (authorization code + PKCE, with or without a client
|
|
4
|
+
* secret), using only `fetch` and Web Crypto: it runs on Node 18+, Next.js (Node and Edge),
|
|
5
|
+
* Cloudflare Workers, Deno and Bun. Framework packages keep the transaction and the session
|
|
6
|
+
* (for example in encrypted cookies); this class does the protocol:
|
|
7
|
+
*
|
|
8
|
+
* const { url, transaction } = await auth.createLogin({ returnTo: "/dashboard" });
|
|
9
|
+
* // keep `transaction` until the callback, redirect the browser to `url`
|
|
10
|
+
* const { tokens, claims, returnTo } = await auth.handleCallback(request.url, transaction);
|
|
11
|
+
*/
|
|
12
|
+
export interface WebAppAuthOptions {
|
|
13
|
+
/** Your tenant's issuer URL, e.g. https://acme.9in.io. */
|
|
14
|
+
issuer: string;
|
|
15
|
+
clientId: string;
|
|
16
|
+
/** For confidential applications: sent with HTTP Basic. Leave out for public ones (PKCE only). */
|
|
17
|
+
clientSecret?: string;
|
|
18
|
+
/** Your callback URL, as registered on the application. */
|
|
19
|
+
redirectUri: string;
|
|
20
|
+
/** Default "openid profile email offline_access" (offline_access gives a refresh token). */
|
|
21
|
+
scope?: string;
|
|
22
|
+
/** An API identifier, to get access tokens for that API. */
|
|
23
|
+
audience?: string;
|
|
24
|
+
/** Only for tenants that sign with a shared (HMAC) key. */
|
|
25
|
+
idTokenSecret?: string;
|
|
26
|
+
fetch?: typeof fetch;
|
|
27
|
+
/** Allows http:// issuers other than localhost (never in production). */
|
|
28
|
+
allowInsecureIssuer?: boolean;
|
|
29
|
+
}
|
|
30
|
+
export interface WebLoginOptions {
|
|
31
|
+
/** A path in your app to come back to after sign-in. */
|
|
32
|
+
returnTo?: string;
|
|
33
|
+
prompt?: "login" | "consent" | "none" | "select_account" | "create";
|
|
34
|
+
/** Prefills the identifier on the sign-in page. */
|
|
35
|
+
loginHint?: string;
|
|
36
|
+
/** "signup" opens the registration page. */
|
|
37
|
+
screen?: "login" | "signup";
|
|
38
|
+
/** Language of the hosted pages (ui_locales), e.g. "de". */
|
|
39
|
+
locale?: string;
|
|
40
|
+
/** Extra authorization parameters (they never replace state, nonce or PKCE). */
|
|
41
|
+
params?: Record<string, string>;
|
|
42
|
+
}
|
|
43
|
+
/** What to keep between the redirect to 9in and the callback (store it encrypted; it's secret). */
|
|
44
|
+
export interface LoginTransaction {
|
|
45
|
+
state: string;
|
|
46
|
+
nonce: string;
|
|
47
|
+
verifier: string;
|
|
48
|
+
returnTo?: string;
|
|
49
|
+
/** ms */
|
|
50
|
+
createdAt: number;
|
|
51
|
+
}
|
|
52
|
+
export interface WebTokenSet {
|
|
53
|
+
accessToken: string;
|
|
54
|
+
idToken: string | null;
|
|
55
|
+
refreshToken: string | null;
|
|
56
|
+
/** ms */
|
|
57
|
+
expiresAt: number;
|
|
58
|
+
scope: string;
|
|
59
|
+
tokenType: string;
|
|
60
|
+
}
|
|
61
|
+
export interface CallbackResult {
|
|
62
|
+
tokens: WebTokenSet;
|
|
63
|
+
/** The verified ID token's claims: the signed-in user. */
|
|
64
|
+
claims: IdTokenClaims;
|
|
65
|
+
returnTo?: string;
|
|
66
|
+
}
|
|
67
|
+
export declare class WebAppAuth {
|
|
68
|
+
private readonly options;
|
|
69
|
+
private readonly issuer;
|
|
70
|
+
private readonly fetcher;
|
|
71
|
+
constructor(options: WebAppAuthOptions);
|
|
72
|
+
/** The URL to send the browser to, and the transaction to keep until the callback. */
|
|
73
|
+
createLogin(options?: WebLoginOptions): Promise<{
|
|
74
|
+
url: string;
|
|
75
|
+
transaction: LoginTransaction;
|
|
76
|
+
}>;
|
|
77
|
+
/**
|
|
78
|
+
* Finishes sign-in on your callback URL: checks the state against the transaction, exchanges
|
|
79
|
+
* the code and verifies the ID token (signature, issuer, audience, expiry, nonce).
|
|
80
|
+
*/
|
|
81
|
+
handleCallback(callbackUrl: string | URL, transaction: LoginTransaction | null | undefined): Promise<CallbackResult>;
|
|
82
|
+
/** New tokens from a refresh token (9in rotates it: keep the one returned). */
|
|
83
|
+
refresh(refreshToken: string): Promise<WebTokenSet>;
|
|
84
|
+
/** The URL that ends the session at 9in and comes back to `postLogoutRedirectUri`. */
|
|
85
|
+
logoutUrl(options?: {
|
|
86
|
+
idTokenHint?: string | null;
|
|
87
|
+
postLogoutRedirectUri?: string;
|
|
88
|
+
state?: string;
|
|
89
|
+
}): Promise<string>;
|
|
90
|
+
/** Revokes a refresh token (ending the session's token family) or an access token. */
|
|
91
|
+
revoke(token: string, hint?: "refresh_token" | "access_token"): Promise<void>;
|
|
92
|
+
/** The user's current profile from the userinfo endpoint. */
|
|
93
|
+
userInfo(accessToken: string): Promise<Record<string, unknown>>;
|
|
94
|
+
private clientAuth;
|
|
95
|
+
private tokenRequest;
|
|
96
|
+
}
|
package/dist/web-app.js
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { NineError, base64UrlEncode, discover, normalizeIssuer, postForm, randomString, sha256 } from "./util.js";
|
|
2
|
+
import { verifyIdToken } from "./server.js";
|
|
3
|
+
const TRANSACTION_TTL = 15 * 60_000;
|
|
4
|
+
const RESERVED = new Set(["response_type", "client_id", "redirect_uri", "state", "nonce", "code_challenge", "code_challenge_method", "scope"]);
|
|
5
|
+
export class WebAppAuth {
|
|
6
|
+
options;
|
|
7
|
+
issuer;
|
|
8
|
+
fetcher;
|
|
9
|
+
constructor(options) {
|
|
10
|
+
this.options = options;
|
|
11
|
+
if (!options.clientId)
|
|
12
|
+
throw new NineError("invalid_options", "clientId is required");
|
|
13
|
+
if (!options.redirectUri)
|
|
14
|
+
throw new NineError("invalid_options", "redirectUri is required");
|
|
15
|
+
this.issuer = normalizeIssuer(options.issuer, options.allowInsecureIssuer);
|
|
16
|
+
this.fetcher = options.fetch ?? fetch.bind(globalThis);
|
|
17
|
+
}
|
|
18
|
+
/** The URL to send the browser to, and the transaction to keep until the callback. */
|
|
19
|
+
async createLogin(options = {}) {
|
|
20
|
+
const config = await discover(this.issuer, this.fetcher);
|
|
21
|
+
const transaction = {
|
|
22
|
+
state: randomString(24),
|
|
23
|
+
nonce: randomString(24),
|
|
24
|
+
verifier: randomString(48),
|
|
25
|
+
createdAt: Date.now(),
|
|
26
|
+
...(options.returnTo ? { returnTo: options.returnTo } : {}),
|
|
27
|
+
};
|
|
28
|
+
const url = new URL(config.authorization_endpoint);
|
|
29
|
+
const params = url.searchParams;
|
|
30
|
+
for (const [key, value] of Object.entries(options.params ?? {}))
|
|
31
|
+
if (!RESERVED.has(key))
|
|
32
|
+
params.set(key, value);
|
|
33
|
+
params.set("response_type", "code");
|
|
34
|
+
params.set("client_id", this.options.clientId);
|
|
35
|
+
params.set("redirect_uri", this.options.redirectUri);
|
|
36
|
+
params.set("scope", this.options.scope ?? "openid profile email offline_access");
|
|
37
|
+
params.set("state", transaction.state);
|
|
38
|
+
params.set("nonce", transaction.nonce);
|
|
39
|
+
params.set("code_challenge", base64UrlEncode(await sha256(transaction.verifier)));
|
|
40
|
+
params.set("code_challenge_method", "S256");
|
|
41
|
+
if (this.options.audience)
|
|
42
|
+
params.set("audience", this.options.audience);
|
|
43
|
+
const prompt = options.screen === "signup" ? "create" : options.prompt;
|
|
44
|
+
if (prompt)
|
|
45
|
+
params.set("prompt", prompt);
|
|
46
|
+
if (options.loginHint)
|
|
47
|
+
params.set("login_hint", options.loginHint);
|
|
48
|
+
if (options.locale)
|
|
49
|
+
params.set("ui_locales", options.locale);
|
|
50
|
+
return { url: url.toString(), transaction };
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Finishes sign-in on your callback URL: checks the state against the transaction, exchanges
|
|
54
|
+
* the code and verifies the ID token (signature, issuer, audience, expiry, nonce).
|
|
55
|
+
*/
|
|
56
|
+
async handleCallback(callbackUrl, transaction) {
|
|
57
|
+
const url = new URL(callbackUrl);
|
|
58
|
+
const error = url.searchParams.get("error");
|
|
59
|
+
if (error)
|
|
60
|
+
throw new NineError(error, url.searchParams.get("error_description") ?? error, 400);
|
|
61
|
+
if (!transaction)
|
|
62
|
+
throw new NineError("invalid_state", "no sign-in in progress (it expired, or started in another browser)", 400);
|
|
63
|
+
if (Date.now() - transaction.createdAt > TRANSACTION_TTL)
|
|
64
|
+
throw new NineError("invalid_state", "the sign-in took too long; start again", 400);
|
|
65
|
+
if (url.searchParams.get("state") !== transaction.state)
|
|
66
|
+
throw new NineError("invalid_state", "state mismatch", 400);
|
|
67
|
+
// RFC 9207: when the issuer says who it is, it must be ours.
|
|
68
|
+
const iss = url.searchParams.get("iss");
|
|
69
|
+
if (iss && iss.replace(/\/$/, "") !== this.issuer)
|
|
70
|
+
throw new NineError("invalid_issuer", `unexpected issuer ${iss}`, 400);
|
|
71
|
+
const code = url.searchParams.get("code");
|
|
72
|
+
if (!code)
|
|
73
|
+
throw new NineError("missing_code", "no authorization code in the URL", 400);
|
|
74
|
+
const tokens = await this.tokenRequest({ grant_type: "authorization_code", code, redirect_uri: this.options.redirectUri, code_verifier: transaction.verifier });
|
|
75
|
+
const claims = await verifyIdToken(tokens.idToken, {
|
|
76
|
+
issuer: this.issuer,
|
|
77
|
+
clientId: this.options.clientId,
|
|
78
|
+
nonce: transaction.nonce,
|
|
79
|
+
secret: this.options.idTokenSecret,
|
|
80
|
+
fetch: this.fetcher,
|
|
81
|
+
allowInsecureIssuer: this.options.allowInsecureIssuer,
|
|
82
|
+
});
|
|
83
|
+
return { tokens, claims, ...(transaction.returnTo ? { returnTo: transaction.returnTo } : {}) };
|
|
84
|
+
}
|
|
85
|
+
/** New tokens from a refresh token (9in rotates it: keep the one returned). */
|
|
86
|
+
async refresh(refreshToken) {
|
|
87
|
+
const tokens = await this.tokenRequest({ grant_type: "refresh_token", refresh_token: refreshToken });
|
|
88
|
+
return { ...tokens, refreshToken: tokens.refreshToken ?? refreshToken };
|
|
89
|
+
}
|
|
90
|
+
/** The URL that ends the session at 9in and comes back to `postLogoutRedirectUri`. */
|
|
91
|
+
async logoutUrl(options = {}) {
|
|
92
|
+
const config = await discover(this.issuer, this.fetcher);
|
|
93
|
+
const url = new URL(config.end_session_endpoint ?? `${this.issuer}/logout`);
|
|
94
|
+
url.searchParams.set("client_id", this.options.clientId);
|
|
95
|
+
if (options.idTokenHint)
|
|
96
|
+
url.searchParams.set("id_token_hint", options.idTokenHint);
|
|
97
|
+
if (options.postLogoutRedirectUri)
|
|
98
|
+
url.searchParams.set("post_logout_redirect_uri", options.postLogoutRedirectUri);
|
|
99
|
+
if (options.state)
|
|
100
|
+
url.searchParams.set("state", options.state);
|
|
101
|
+
return url.toString();
|
|
102
|
+
}
|
|
103
|
+
/** Revokes a refresh token (ending the session's token family) or an access token. */
|
|
104
|
+
async revoke(token, hint = "refresh_token") {
|
|
105
|
+
const config = await discover(this.issuer, this.fetcher);
|
|
106
|
+
if (!config.revocation_endpoint)
|
|
107
|
+
return;
|
|
108
|
+
const { form, headers } = this.clientAuth({ token, token_type_hint: hint });
|
|
109
|
+
await postForm(config.revocation_endpoint, form, headers, this.fetcher);
|
|
110
|
+
}
|
|
111
|
+
/** The user's current profile from the userinfo endpoint. */
|
|
112
|
+
async userInfo(accessToken) {
|
|
113
|
+
const config = await discover(this.issuer, this.fetcher);
|
|
114
|
+
const res = await this.fetcher(config.userinfo_endpoint, { headers: { Authorization: `Bearer ${accessToken}`, Accept: "application/json" } });
|
|
115
|
+
if (!res.ok)
|
|
116
|
+
throw new NineError(res.status === 401 ? "invalid_token" : "userinfo_failed", `HTTP ${res.status}`, res.status);
|
|
117
|
+
return (await res.json());
|
|
118
|
+
}
|
|
119
|
+
clientAuth(form) {
|
|
120
|
+
if (!this.options.clientSecret)
|
|
121
|
+
return { form: { ...form, client_id: this.options.clientId }, headers: {} };
|
|
122
|
+
const basic = btoa(`${encodeURIComponent(this.options.clientId)}:${encodeURIComponent(this.options.clientSecret)}`);
|
|
123
|
+
return { form, headers: { Authorization: `Basic ${basic}` } };
|
|
124
|
+
}
|
|
125
|
+
async tokenRequest(input) {
|
|
126
|
+
const config = await discover(this.issuer, this.fetcher);
|
|
127
|
+
const { form, headers } = this.clientAuth(input);
|
|
128
|
+
const json = await postForm(config.token_endpoint, form, headers, this.fetcher);
|
|
129
|
+
return {
|
|
130
|
+
accessToken: json.access_token,
|
|
131
|
+
idToken: json.id_token ?? null,
|
|
132
|
+
refreshToken: json.refresh_token ?? null,
|
|
133
|
+
expiresAt: Date.now() + (json.expires_in ?? 3600) * 1000,
|
|
134
|
+
scope: json.scope ?? "",
|
|
135
|
+
tokenType: json.token_type ?? "Bearer",
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@9in-io/sdk",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Official 9in SDK: browser sign-in with PKCE, token verification, Management API client and adapter handler. Zero dependencies; runs in browsers, Node 18+, Cloudflare Workers, Deno and Bun.",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Official 9in SDK: browser and server-side sign-in with PKCE, token verification, Management API client and adapter handler. Zero dependencies; runs in browsers, Node 18+, Cloudflare Workers, Deno and Bun.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"sideEffects": false,
|
package/src/server.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { NineError, decodeJwt, discover, normalizeIssuer, postForm, toHex, utf8 } from "./util.js";
|
|
2
2
|
|
|
3
3
|
export { NineError, normalizeIssuer } from "./util.js";
|
|
4
|
+
export { WebAppAuth, type WebAppAuthOptions, type WebLoginOptions, type LoginTransaction, type WebTokenSet, type CallbackResult } from "./web-app.js";
|
|
4
5
|
|
|
5
6
|
// ---------------------------------------------------------------------------
|
|
6
7
|
// Access token verification
|
|
@@ -151,6 +152,80 @@ export async function verifyAccessToken(token: string | null | undefined, option
|
|
|
151
152
|
return payload as AccessTokenClaims;
|
|
152
153
|
}
|
|
153
154
|
|
|
155
|
+
export interface VerifyIdTokenOptions {
|
|
156
|
+
/** Your tenant's issuer URL. */
|
|
157
|
+
issuer: string;
|
|
158
|
+
/** Your application's client ID (the token's audience). */
|
|
159
|
+
clientId: string;
|
|
160
|
+
/** The nonce sent with the sign-in request. */
|
|
161
|
+
nonce?: string | null;
|
|
162
|
+
/** For tenants whose signing key is a shared (HMAC) secret. */
|
|
163
|
+
secret?: string;
|
|
164
|
+
/** Seconds of clock skew allowed (default 30). */
|
|
165
|
+
clockTolerance?: number;
|
|
166
|
+
fetch?: typeof fetch;
|
|
167
|
+
allowInsecureIssuer?: boolean;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Claims of a verified ID token (standard OIDC claims plus your custom profile claims). */
|
|
171
|
+
export interface IdTokenClaims {
|
|
172
|
+
iss: string;
|
|
173
|
+
sub: string;
|
|
174
|
+
aud: string | string[];
|
|
175
|
+
exp: number;
|
|
176
|
+
iat: number;
|
|
177
|
+
nonce?: string;
|
|
178
|
+
auth_time?: number;
|
|
179
|
+
/** How the user signed in, e.g. ["pwd", "mfa", "otp"]. */
|
|
180
|
+
amr?: string[];
|
|
181
|
+
sid?: string;
|
|
182
|
+
email?: string;
|
|
183
|
+
email_verified?: boolean;
|
|
184
|
+
name?: string;
|
|
185
|
+
picture?: string;
|
|
186
|
+
[claim: string]: unknown;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Verifies an ID token from your tenant: signature (JWKS, or the shared secret for HMAC keys),
|
|
191
|
+
* issuer, audience (your client ID; `azp` when there are several), expiry and nonce.
|
|
192
|
+
*/
|
|
193
|
+
export async function verifyIdToken(token: string | null | undefined, options: VerifyIdTokenOptions): Promise<IdTokenClaims> {
|
|
194
|
+
if (!token) throw new NineError("invalid_token", "missing ID token", 401);
|
|
195
|
+
const fetcher = options.fetch ?? fetch.bind(globalThis);
|
|
196
|
+
const issuer = normalizeIssuer(options.issuer, options.allowInsecureIssuer);
|
|
197
|
+
let parts;
|
|
198
|
+
try {
|
|
199
|
+
parts = decodeJwt(token);
|
|
200
|
+
} catch {
|
|
201
|
+
throw new NineError("invalid_token", "malformed ID token", 401);
|
|
202
|
+
}
|
|
203
|
+
const { header, payload, signingInput, signature } = parts;
|
|
204
|
+
const alg = String(header.alg);
|
|
205
|
+
// An access token is never accepted as an ID token.
|
|
206
|
+
if (ACCESS_TOKEN_TYPES.has(String(header.typ ?? "").toLowerCase())) throw new NineError("invalid_token", "not an ID token", 401);
|
|
207
|
+
let valid: boolean;
|
|
208
|
+
if (HMAC_HASH[alg]) {
|
|
209
|
+
if (!options.secret) throw new NineError("invalid_token", "HMAC-signed ID token: pass the tenant's shared secret", 401);
|
|
210
|
+
const key = await crypto.subtle.importKey("raw", utf8(options.secret), { name: "HMAC", hash: HMAC_HASH[alg] }, false, ["verify"]);
|
|
211
|
+
valid = await crypto.subtle.verify("HMAC", key, signature, utf8(signingInput));
|
|
212
|
+
} else if (ALGORITHMS[alg]) {
|
|
213
|
+
const jwk = await findKey(issuer, header.kid, fetcher);
|
|
214
|
+
if (!jwk) throw new NineError("invalid_token", "unknown signing key", 401);
|
|
215
|
+
if (jwk.alg && jwk.alg !== alg) throw new NineError("invalid_token", "algorithm does not match the key", 401);
|
|
216
|
+
valid = await crypto.subtle.verify(ALGORITHMS[alg]!.verify, await importPublicKey(jwk, alg), signature, utf8(signingInput));
|
|
217
|
+
} else {
|
|
218
|
+
throw new NineError("invalid_token", "unsupported algorithm", 401);
|
|
219
|
+
}
|
|
220
|
+
if (!valid) throw new NineError("invalid_token", "bad signature", 401);
|
|
221
|
+
checkClaims(payload, issuer, { issuer, audience: options.clientId, clockTolerance: options.clockTolerance });
|
|
222
|
+
const aud = Array.isArray(payload.aud) ? payload.aud : [payload.aud];
|
|
223
|
+
if (aud.length > 1 && payload.azp !== options.clientId) throw new NineError("invalid_token", "authorized party mismatch", 401);
|
|
224
|
+
if (options.nonce !== undefined && options.nonce !== null && payload.nonce !== options.nonce) throw new NineError("invalid_token", "nonce mismatch", 401);
|
|
225
|
+
if (typeof payload.sub !== "string" || !payload.sub) throw new NineError("invalid_token", "missing subject", 401);
|
|
226
|
+
return payload as IdTokenClaims;
|
|
227
|
+
}
|
|
228
|
+
|
|
154
229
|
function checkClaims(payload: Record<string, unknown>, issuer: string, options: VerifyOptions): void {
|
|
155
230
|
const now = Math.floor(Date.now() / 1000);
|
|
156
231
|
const skew = options.clockTolerance ?? 30;
|
package/src/web-app.ts
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { NineError, base64UrlEncode, discover, normalizeIssuer, postForm, randomString, sha256 } from "./util.js";
|
|
2
|
+
import { verifyIdToken, type IdTokenClaims } from "./server.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Sign-in for server-rendered web apps (authorization code + PKCE, with or without a client
|
|
6
|
+
* secret), using only `fetch` and Web Crypto: it runs on Node 18+, Next.js (Node and Edge),
|
|
7
|
+
* Cloudflare Workers, Deno and Bun. Framework packages keep the transaction and the session
|
|
8
|
+
* (for example in encrypted cookies); this class does the protocol:
|
|
9
|
+
*
|
|
10
|
+
* const { url, transaction } = await auth.createLogin({ returnTo: "/dashboard" });
|
|
11
|
+
* // keep `transaction` until the callback, redirect the browser to `url`
|
|
12
|
+
* const { tokens, claims, returnTo } = await auth.handleCallback(request.url, transaction);
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export interface WebAppAuthOptions {
|
|
16
|
+
/** Your tenant's issuer URL, e.g. https://acme.9in.io. */
|
|
17
|
+
issuer: string;
|
|
18
|
+
clientId: string;
|
|
19
|
+
/** For confidential applications: sent with HTTP Basic. Leave out for public ones (PKCE only). */
|
|
20
|
+
clientSecret?: string;
|
|
21
|
+
/** Your callback URL, as registered on the application. */
|
|
22
|
+
redirectUri: string;
|
|
23
|
+
/** Default "openid profile email offline_access" (offline_access gives a refresh token). */
|
|
24
|
+
scope?: string;
|
|
25
|
+
/** An API identifier, to get access tokens for that API. */
|
|
26
|
+
audience?: string;
|
|
27
|
+
/** Only for tenants that sign with a shared (HMAC) key. */
|
|
28
|
+
idTokenSecret?: string;
|
|
29
|
+
fetch?: typeof fetch;
|
|
30
|
+
/** Allows http:// issuers other than localhost (never in production). */
|
|
31
|
+
allowInsecureIssuer?: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface WebLoginOptions {
|
|
35
|
+
/** A path in your app to come back to after sign-in. */
|
|
36
|
+
returnTo?: string;
|
|
37
|
+
prompt?: "login" | "consent" | "none" | "select_account" | "create";
|
|
38
|
+
/** Prefills the identifier on the sign-in page. */
|
|
39
|
+
loginHint?: string;
|
|
40
|
+
/** "signup" opens the registration page. */
|
|
41
|
+
screen?: "login" | "signup";
|
|
42
|
+
/** Language of the hosted pages (ui_locales), e.g. "de". */
|
|
43
|
+
locale?: string;
|
|
44
|
+
/** Extra authorization parameters (they never replace state, nonce or PKCE). */
|
|
45
|
+
params?: Record<string, string>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** What to keep between the redirect to 9in and the callback (store it encrypted; it's secret). */
|
|
49
|
+
export interface LoginTransaction {
|
|
50
|
+
state: string;
|
|
51
|
+
nonce: string;
|
|
52
|
+
verifier: string;
|
|
53
|
+
returnTo?: string;
|
|
54
|
+
/** ms */
|
|
55
|
+
createdAt: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface WebTokenSet {
|
|
59
|
+
accessToken: string;
|
|
60
|
+
idToken: string | null;
|
|
61
|
+
refreshToken: string | null;
|
|
62
|
+
/** ms */
|
|
63
|
+
expiresAt: number;
|
|
64
|
+
scope: string;
|
|
65
|
+
tokenType: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface CallbackResult {
|
|
69
|
+
tokens: WebTokenSet;
|
|
70
|
+
/** The verified ID token's claims: the signed-in user. */
|
|
71
|
+
claims: IdTokenClaims;
|
|
72
|
+
returnTo?: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const TRANSACTION_TTL = 15 * 60_000;
|
|
76
|
+
const RESERVED = new Set(["response_type", "client_id", "redirect_uri", "state", "nonce", "code_challenge", "code_challenge_method", "scope"]);
|
|
77
|
+
|
|
78
|
+
interface TokenResponse {
|
|
79
|
+
access_token: string;
|
|
80
|
+
id_token?: string;
|
|
81
|
+
refresh_token?: string;
|
|
82
|
+
expires_in?: number;
|
|
83
|
+
scope?: string;
|
|
84
|
+
token_type?: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export class WebAppAuth {
|
|
88
|
+
private readonly issuer: string;
|
|
89
|
+
private readonly fetcher: typeof fetch;
|
|
90
|
+
|
|
91
|
+
constructor(private readonly options: WebAppAuthOptions) {
|
|
92
|
+
if (!options.clientId) throw new NineError("invalid_options", "clientId is required");
|
|
93
|
+
if (!options.redirectUri) throw new NineError("invalid_options", "redirectUri is required");
|
|
94
|
+
this.issuer = normalizeIssuer(options.issuer, options.allowInsecureIssuer);
|
|
95
|
+
this.fetcher = options.fetch ?? fetch.bind(globalThis);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The URL to send the browser to, and the transaction to keep until the callback. */
|
|
99
|
+
async createLogin(options: WebLoginOptions = {}): Promise<{ url: string; transaction: LoginTransaction }> {
|
|
100
|
+
const config = await discover(this.issuer, this.fetcher);
|
|
101
|
+
const transaction: LoginTransaction = {
|
|
102
|
+
state: randomString(24),
|
|
103
|
+
nonce: randomString(24),
|
|
104
|
+
verifier: randomString(48),
|
|
105
|
+
createdAt: Date.now(),
|
|
106
|
+
...(options.returnTo ? { returnTo: options.returnTo } : {}),
|
|
107
|
+
};
|
|
108
|
+
const url = new URL(config.authorization_endpoint);
|
|
109
|
+
const params = url.searchParams;
|
|
110
|
+
for (const [key, value] of Object.entries(options.params ?? {})) if (!RESERVED.has(key)) params.set(key, value);
|
|
111
|
+
params.set("response_type", "code");
|
|
112
|
+
params.set("client_id", this.options.clientId);
|
|
113
|
+
params.set("redirect_uri", this.options.redirectUri);
|
|
114
|
+
params.set("scope", this.options.scope ?? "openid profile email offline_access");
|
|
115
|
+
params.set("state", transaction.state);
|
|
116
|
+
params.set("nonce", transaction.nonce);
|
|
117
|
+
params.set("code_challenge", base64UrlEncode(await sha256(transaction.verifier)));
|
|
118
|
+
params.set("code_challenge_method", "S256");
|
|
119
|
+
if (this.options.audience) params.set("audience", this.options.audience);
|
|
120
|
+
const prompt = options.screen === "signup" ? "create" : options.prompt;
|
|
121
|
+
if (prompt) params.set("prompt", prompt);
|
|
122
|
+
if (options.loginHint) params.set("login_hint", options.loginHint);
|
|
123
|
+
if (options.locale) params.set("ui_locales", options.locale);
|
|
124
|
+
return { url: url.toString(), transaction };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Finishes sign-in on your callback URL: checks the state against the transaction, exchanges
|
|
129
|
+
* the code and verifies the ID token (signature, issuer, audience, expiry, nonce).
|
|
130
|
+
*/
|
|
131
|
+
async handleCallback(callbackUrl: string | URL, transaction: LoginTransaction | null | undefined): Promise<CallbackResult> {
|
|
132
|
+
const url = new URL(callbackUrl);
|
|
133
|
+
const error = url.searchParams.get("error");
|
|
134
|
+
if (error) throw new NineError(error, url.searchParams.get("error_description") ?? error, 400);
|
|
135
|
+
if (!transaction) throw new NineError("invalid_state", "no sign-in in progress (it expired, or started in another browser)", 400);
|
|
136
|
+
if (Date.now() - transaction.createdAt > TRANSACTION_TTL) throw new NineError("invalid_state", "the sign-in took too long; start again", 400);
|
|
137
|
+
if (url.searchParams.get("state") !== transaction.state) throw new NineError("invalid_state", "state mismatch", 400);
|
|
138
|
+
// RFC 9207: when the issuer says who it is, it must be ours.
|
|
139
|
+
const iss = url.searchParams.get("iss");
|
|
140
|
+
if (iss && iss.replace(/\/$/, "") !== this.issuer) throw new NineError("invalid_issuer", `unexpected issuer ${iss}`, 400);
|
|
141
|
+
const code = url.searchParams.get("code");
|
|
142
|
+
if (!code) throw new NineError("missing_code", "no authorization code in the URL", 400);
|
|
143
|
+
const tokens = await this.tokenRequest({ grant_type: "authorization_code", code, redirect_uri: this.options.redirectUri, code_verifier: transaction.verifier });
|
|
144
|
+
const claims = await verifyIdToken(tokens.idToken, {
|
|
145
|
+
issuer: this.issuer,
|
|
146
|
+
clientId: this.options.clientId,
|
|
147
|
+
nonce: transaction.nonce,
|
|
148
|
+
secret: this.options.idTokenSecret,
|
|
149
|
+
fetch: this.fetcher,
|
|
150
|
+
allowInsecureIssuer: this.options.allowInsecureIssuer,
|
|
151
|
+
});
|
|
152
|
+
return { tokens, claims, ...(transaction.returnTo ? { returnTo: transaction.returnTo } : {}) };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** New tokens from a refresh token (9in rotates it: keep the one returned). */
|
|
156
|
+
async refresh(refreshToken: string): Promise<WebTokenSet> {
|
|
157
|
+
const tokens = await this.tokenRequest({ grant_type: "refresh_token", refresh_token: refreshToken });
|
|
158
|
+
return { ...tokens, refreshToken: tokens.refreshToken ?? refreshToken };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** The URL that ends the session at 9in and comes back to `postLogoutRedirectUri`. */
|
|
162
|
+
async logoutUrl(options: { idTokenHint?: string | null; postLogoutRedirectUri?: string; state?: string } = {}): Promise<string> {
|
|
163
|
+
const config = await discover(this.issuer, this.fetcher);
|
|
164
|
+
const url = new URL(config.end_session_endpoint ?? `${this.issuer}/logout`);
|
|
165
|
+
url.searchParams.set("client_id", this.options.clientId);
|
|
166
|
+
if (options.idTokenHint) url.searchParams.set("id_token_hint", options.idTokenHint);
|
|
167
|
+
if (options.postLogoutRedirectUri) url.searchParams.set("post_logout_redirect_uri", options.postLogoutRedirectUri);
|
|
168
|
+
if (options.state) url.searchParams.set("state", options.state);
|
|
169
|
+
return url.toString();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Revokes a refresh token (ending the session's token family) or an access token. */
|
|
173
|
+
async revoke(token: string, hint: "refresh_token" | "access_token" = "refresh_token"): Promise<void> {
|
|
174
|
+
const config = await discover(this.issuer, this.fetcher);
|
|
175
|
+
if (!config.revocation_endpoint) return;
|
|
176
|
+
const { form, headers } = this.clientAuth({ token, token_type_hint: hint });
|
|
177
|
+
await postForm(config.revocation_endpoint, form, headers, this.fetcher);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** The user's current profile from the userinfo endpoint. */
|
|
181
|
+
async userInfo(accessToken: string): Promise<Record<string, unknown>> {
|
|
182
|
+
const config = await discover(this.issuer, this.fetcher);
|
|
183
|
+
const res = await this.fetcher(config.userinfo_endpoint, { headers: { Authorization: `Bearer ${accessToken}`, Accept: "application/json" } });
|
|
184
|
+
if (!res.ok) throw new NineError(res.status === 401 ? "invalid_token" : "userinfo_failed", `HTTP ${res.status}`, res.status);
|
|
185
|
+
return (await res.json()) as Record<string, unknown>;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private clientAuth(form: Record<string, string>): { form: Record<string, string>; headers: Record<string, string> } {
|
|
189
|
+
if (!this.options.clientSecret) return { form: { ...form, client_id: this.options.clientId }, headers: {} };
|
|
190
|
+
const basic = btoa(`${encodeURIComponent(this.options.clientId)}:${encodeURIComponent(this.options.clientSecret)}`);
|
|
191
|
+
return { form, headers: { Authorization: `Basic ${basic}` } };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
private async tokenRequest(input: Record<string, string>): Promise<WebTokenSet> {
|
|
195
|
+
const config = await discover(this.issuer, this.fetcher);
|
|
196
|
+
const { form, headers } = this.clientAuth(input);
|
|
197
|
+
const json = await postForm<TokenResponse>(config.token_endpoint, form, headers, this.fetcher);
|
|
198
|
+
return {
|
|
199
|
+
accessToken: json.access_token,
|
|
200
|
+
idToken: json.id_token ?? null,
|
|
201
|
+
refreshToken: json.refresh_token ?? null,
|
|
202
|
+
expiresAt: Date.now() + (json.expires_in ?? 3600) * 1000,
|
|
203
|
+
scope: json.scope ?? "",
|
|
204
|
+
tokenType: json.token_type ?? "Bearer",
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
}
|