@broberg/sso 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 +76 -0
- package/dist/hono.cjs +510 -0
- package/dist/hono.cjs.map +1 -0
- package/dist/hono.d.cts +199 -0
- package/dist/hono.d.ts +199 -0
- package/dist/hono.js +507 -0
- package/dist/hono.js.map +1 -0
- package/dist/index.cjs +400 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +253 -0
- package/dist/index.d.ts +253 -0
- package/dist/index.js +386 -0
- package/dist/index.js.map +1 -0
- package/package.json +59 -0
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { importJWK, JWTPayload } from 'jose';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Configuration, read from the environment and nowhere else (F084.4 AC#5).
|
|
5
|
+
*
|
|
6
|
+
* An app mounts this package and sets env vars. It does not pass options in
|
|
7
|
+
* code, because the moment configuration lives in code, two deployments of the
|
|
8
|
+
* same app can disagree about who their identity provider is — and the symptom
|
|
9
|
+
* is a token rejection nobody can trace back to a config line.
|
|
10
|
+
*/
|
|
11
|
+
declare class SsoConfigError extends Error {
|
|
12
|
+
constructor(message: string);
|
|
13
|
+
}
|
|
14
|
+
interface SsoConfig {
|
|
15
|
+
/** Broberg ID's origin, e.g. https://id.broberg.ai — the BARE origin. */
|
|
16
|
+
issuer: string;
|
|
17
|
+
/** The client id this app was registered under (administratively, in BID). */
|
|
18
|
+
clientId: string;
|
|
19
|
+
/** Exactly the redirect registered in BID. Exact match — one character decides. */
|
|
20
|
+
redirectUri: string;
|
|
21
|
+
/** Requested scopes. */
|
|
22
|
+
scopes: string[];
|
|
23
|
+
/** Signs the local session cookie. 32+ bytes of randomness. */
|
|
24
|
+
cookieSecret: string;
|
|
25
|
+
/** Local session cookie name. */
|
|
26
|
+
cookieName: string;
|
|
27
|
+
/**
|
|
28
|
+
* How long this app trusts its OWN session, in seconds.
|
|
29
|
+
*
|
|
30
|
+
* F084.7 decided the fleet's numbers: 7 days for a normal app, and 12 hours
|
|
31
|
+
* + 30 minutes of inactivity for anything holding personal or health data.
|
|
32
|
+
* The default here is the 7 days. An app handling patient data MUST set
|
|
33
|
+
* SSO_SESSION_MAX_AGE=43200 — the default is a normal-app default, not a
|
|
34
|
+
* safe-for-everything one.
|
|
35
|
+
*/
|
|
36
|
+
sessionMaxAge: number;
|
|
37
|
+
/** Where to send the browser after a logout completes. */
|
|
38
|
+
postLogoutRedirectUri?: string;
|
|
39
|
+
}
|
|
40
|
+
/** Seconds in a week — the fleet default from F084.7. */
|
|
41
|
+
declare const DEFAULT_SESSION_MAX_AGE: number;
|
|
42
|
+
declare function loadSsoConfig(env?: NodeJS.ProcessEnv): SsoConfig;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The signing-key cache.
|
|
46
|
+
*
|
|
47
|
+
* ── THE ONE BEHAVIOUR THIS FILE EXISTS FOR ────────────────────────────────
|
|
48
|
+
*
|
|
49
|
+
* It refetches on an UNKNOWN KEY ID, not on a timer (F084.4's constraint, and
|
|
50
|
+
* it is the right one). An interval is a guess about when somebody else will
|
|
51
|
+
* rotate their key; an unknown kid is the event itself. With an interval, the
|
|
52
|
+
* window between "BID rotated" and "the interval elapsed" is a window where
|
|
53
|
+
* every login in every app fails, and the length of that window is a number
|
|
54
|
+
* nobody chose on purpose.
|
|
55
|
+
*
|
|
56
|
+
* ── AND THE PART AN OBVIOUS IMPLEMENTATION GETS WRONG ─────────────────────
|
|
57
|
+
*
|
|
58
|
+
* "Unknown kid ⇒ refetch" turns anyone who can send this app a token into
|
|
59
|
+
* someone who can make it hammer BID: a stream of tokens with random kids is a
|
|
60
|
+
* stream of fetches against the one service the whole fleet logs in through.
|
|
61
|
+
* So a refetch is rate-limited by time. The cost of the floor is real and worth
|
|
62
|
+
* stating: a rotation landing inside the cooldown makes logins fail for up to
|
|
63
|
+
* that many milliseconds. Seconds of failure for one app beats a way to aim
|
|
64
|
+
* traffic at BID from outside.
|
|
65
|
+
*/
|
|
66
|
+
|
|
67
|
+
type SigningKey = Awaited<ReturnType<typeof importJWK>>;
|
|
68
|
+
interface JwksCacheOptions {
|
|
69
|
+
/** Absolute URL of the key set, taken from BID's discovery document. */
|
|
70
|
+
jwksUri: string;
|
|
71
|
+
/**
|
|
72
|
+
* The floor between two refetches. Below it, an unknown kid is rejected
|
|
73
|
+
* without asking BID again.
|
|
74
|
+
*/
|
|
75
|
+
minRefetchIntervalMs?: number;
|
|
76
|
+
/** Injectable for tests. */
|
|
77
|
+
fetchImpl?: typeof fetch;
|
|
78
|
+
/** Injectable for tests, so the cooldown can be exercised without waiting. */
|
|
79
|
+
now?: () => number;
|
|
80
|
+
}
|
|
81
|
+
interface JwksCache {
|
|
82
|
+
/** Resolve a key for this kid, refetching once if it is unknown. */
|
|
83
|
+
getKey(kid: string, alg: string): Promise<SigningKey>;
|
|
84
|
+
/** How many times the remote key set has actually been fetched. */
|
|
85
|
+
readonly fetchCount: number;
|
|
86
|
+
}
|
|
87
|
+
declare class JwksError extends Error {
|
|
88
|
+
constructor(message: string);
|
|
89
|
+
}
|
|
90
|
+
declare function createJwksCache(options: JwksCacheOptions): JwksCache;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The core: framework-free, public-client OAuth 2.1 against Broberg ID.
|
|
94
|
+
*
|
|
95
|
+
* ── THIS PACKAGE IS A PUBLIC CLIENT. THERE IS NO CLIENT SECRET. ───────────
|
|
96
|
+
*
|
|
97
|
+
* Deliberate, and F084.4 AC#4 greps the published tarball to keep it that way.
|
|
98
|
+
* PKCE alone carries the exchange. That is safe here for a reason worth stating
|
|
99
|
+
* rather than assuming: BID matches redirect addresses EXACTLY (measured — one
|
|
100
|
+
* trailing slash is refused), so the authorization code is delivered to this
|
|
101
|
+
* app's own server and nowhere else. An attacker who knows the client id can
|
|
102
|
+
* start a flow; they cannot receive its result.
|
|
103
|
+
*
|
|
104
|
+
* What it buys is the thing the card actually asks for: a secret that does not
|
|
105
|
+
* exist cannot be committed, leaked in a log, copied into a second app, or left
|
|
106
|
+
* behind in a repository someone later makes public.
|
|
107
|
+
*
|
|
108
|
+
* ── AND WHAT THIS PACKAGE MUST NEVER LEARN TO DO ──────────────────────────
|
|
109
|
+
*
|
|
110
|
+
* No passwords. No passkey registration. No social-provider keys. No email
|
|
111
|
+
* verification. All of it lives in BID. A client that CAN do any of it is a
|
|
112
|
+
* client somebody eventually uses to do it — and then the identity rules exist
|
|
113
|
+
* in two places and drift.
|
|
114
|
+
*/
|
|
115
|
+
|
|
116
|
+
interface Discovery {
|
|
117
|
+
issuer: string;
|
|
118
|
+
authorization_endpoint: string;
|
|
119
|
+
token_endpoint: string;
|
|
120
|
+
jwks_uri: string;
|
|
121
|
+
userinfo_endpoint?: string;
|
|
122
|
+
end_session_endpoint?: string;
|
|
123
|
+
}
|
|
124
|
+
declare class SsoError extends Error {
|
|
125
|
+
constructor(message: string);
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* What `beginLogin` produces. Every field except `url` must survive the round
|
|
129
|
+
* trip to BID and back — the adapter stores them in a short-lived cookie.
|
|
130
|
+
*
|
|
131
|
+
* They are NOT optional extras. `state` is what makes the callback provably the
|
|
132
|
+
* answer to THIS request, and `nonce` is what stops a valid token minted for
|
|
133
|
+
* some other login from being replayed into this one.
|
|
134
|
+
*/
|
|
135
|
+
interface LoginStart {
|
|
136
|
+
url: string;
|
|
137
|
+
state: string;
|
|
138
|
+
codeVerifier: string;
|
|
139
|
+
nonce: string;
|
|
140
|
+
}
|
|
141
|
+
interface SsoClaims extends JWTPayload {
|
|
142
|
+
sub: string;
|
|
143
|
+
email?: string;
|
|
144
|
+
name?: string;
|
|
145
|
+
picture?: string;
|
|
146
|
+
email_verified?: boolean;
|
|
147
|
+
}
|
|
148
|
+
interface LoginResult {
|
|
149
|
+
claims: SsoClaims;
|
|
150
|
+
idToken: string;
|
|
151
|
+
accessToken?: string;
|
|
152
|
+
refreshToken?: string;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* `prompt=none` asks BID to answer WITHOUT showing anything (F084.6).
|
|
156
|
+
*
|
|
157
|
+
* The delivery method decides whether it works, and this package only ever
|
|
158
|
+
* produces a URL for a FULL TOP-LEVEL REDIRECT. It never returns anything an
|
|
159
|
+
* app could put in a hidden iframe, because an iframe against the identity
|
|
160
|
+
* provider is third-party context: Safari has blocked it for years and Chrome
|
|
161
|
+
* is retiring it. That path works in testing on a Mac and fails for every user
|
|
162
|
+
* on an iPhone — a failure that looks like "you are not logged in".
|
|
163
|
+
*/
|
|
164
|
+
interface BeginLoginOptions {
|
|
165
|
+
prompt?: "none" | "login" | "consent" | "select_account";
|
|
166
|
+
/** Extra scopes for this one request, on top of the configured set. */
|
|
167
|
+
scopes?: string[];
|
|
168
|
+
}
|
|
169
|
+
interface SsoClient {
|
|
170
|
+
discovery(): Promise<Discovery>;
|
|
171
|
+
beginLogin(options?: BeginLoginOptions): Promise<LoginStart>;
|
|
172
|
+
completeLogin(input: {
|
|
173
|
+
params: URLSearchParams;
|
|
174
|
+
state: string;
|
|
175
|
+
codeVerifier: string;
|
|
176
|
+
nonce: string;
|
|
177
|
+
}): Promise<LoginResult>;
|
|
178
|
+
verifyIdToken(idToken: string, options?: {
|
|
179
|
+
nonce?: string;
|
|
180
|
+
}): Promise<SsoClaims>;
|
|
181
|
+
logoutUrl(options?: {
|
|
182
|
+
idTokenHint?: string;
|
|
183
|
+
postLogoutRedirectUri?: string;
|
|
184
|
+
}): Promise<string>;
|
|
185
|
+
/** Exposed for tests and for a health check; not needed in normal use. */
|
|
186
|
+
readonly jwks: JwksCache;
|
|
187
|
+
}
|
|
188
|
+
interface CreateSsoClientOptions {
|
|
189
|
+
fetchImpl?: typeof fetch;
|
|
190
|
+
/** Passed through to the key cache; see jwks.ts for why there is a floor. */
|
|
191
|
+
minRefetchIntervalMs?: number;
|
|
192
|
+
}
|
|
193
|
+
declare function createSsoClient(config: SsoConfig, options?: CreateSsoClientOptions): SsoClient;
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* The app's OWN session cookie — signed, not encrypted.
|
|
197
|
+
*
|
|
198
|
+
* Signed is the right choice and the distinction matters: the contents are not
|
|
199
|
+
* secret (a user may read their own id and name), but they must not be
|
|
200
|
+
* FORGEABLE. Encryption would hide a subject id the user already knows while
|
|
201
|
+
* doing nothing extra about forgery, which is the actual risk.
|
|
202
|
+
*
|
|
203
|
+
* What goes in is deliberately small: who you are and when this stops being
|
|
204
|
+
* true. Claims that can change — a name, a role, a picture — belong in the
|
|
205
|
+
* app's own store keyed by `sub`, because a cookie is a cache nobody can
|
|
206
|
+
* invalidate, and a stale role in a cookie is a permission that outlives its
|
|
207
|
+
* revocation.
|
|
208
|
+
*/
|
|
209
|
+
interface SessionPayload {
|
|
210
|
+
/** The subject from Broberg ID. The one stable identifier. */
|
|
211
|
+
sub: string;
|
|
212
|
+
/** Unix seconds. Checked on every read. */
|
|
213
|
+
exp: number;
|
|
214
|
+
/** Optional convenience copies; never authorisation data. */
|
|
215
|
+
email?: string;
|
|
216
|
+
name?: string;
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Sign an arbitrary string. The primitive underneath BOTH cookies this package
|
|
220
|
+
* sets — the session and the short-lived login transaction.
|
|
221
|
+
*
|
|
222
|
+
* They are separate functions on purpose. The first version of this file had
|
|
223
|
+
* the transaction ride inside the session envelope with `exp: 0`, and the
|
|
224
|
+
* expiry check (`exp * 1000 <= now()`) then rejected it every single time: the
|
|
225
|
+
* transaction cookie could never be read back, so every login would have failed
|
|
226
|
+
* with "state does not match" — a message pointing at the wrong thing entirely.
|
|
227
|
+
* Two different lifetimes wanted two different envelopes, not one envelope with
|
|
228
|
+
* a sentinel in it.
|
|
229
|
+
*/
|
|
230
|
+
declare function signValue(value: string, secret: string): Promise<string>;
|
|
231
|
+
/** Verify the SIGNATURE only, returning the original string. No expiry notion. */
|
|
232
|
+
declare function verifyValue(token: string | undefined | null, secret: string): Promise<string | null>;
|
|
233
|
+
declare function signSession(payload: SessionPayload, secret: string): Promise<string>;
|
|
234
|
+
/**
|
|
235
|
+
* Returns null for ANY reason the cookie cannot be trusted — tampered,
|
|
236
|
+
* truncated, wrong secret, expired, or simply not one of ours.
|
|
237
|
+
*
|
|
238
|
+
* Deliberately one return value rather than distinguishing them to the caller:
|
|
239
|
+
* an app that can tell "bad signature" from "expired" will eventually branch on
|
|
240
|
+
* it, and there is no branch where a forged cookie should do anything other
|
|
241
|
+
* than what an absent one does.
|
|
242
|
+
*/
|
|
243
|
+
declare function verifySession(token: string | undefined | null, secret: string, now?: () => number): Promise<SessionPayload | null>;
|
|
244
|
+
/** Serialise a Set-Cookie value. `secure` is off only for http://localhost. */
|
|
245
|
+
declare function cookieHeader(name: string, value: string, opts: {
|
|
246
|
+
maxAge: number;
|
|
247
|
+
secure: boolean;
|
|
248
|
+
sameSite?: "Lax" | "Strict";
|
|
249
|
+
path?: string;
|
|
250
|
+
}): string;
|
|
251
|
+
declare function readCookie(header: string | null | undefined, name: string): string | undefined;
|
|
252
|
+
|
|
253
|
+
export { type BeginLoginOptions, type CreateSsoClientOptions, DEFAULT_SESSION_MAX_AGE, type Discovery, type JwksCache, type JwksCacheOptions, JwksError, type LoginResult, type LoginStart, type SessionPayload, type SsoClaims, type SsoClient, type SsoConfig, SsoConfigError, SsoError, cookieHeader, createJwksCache, createSsoClient, loadSsoConfig, readCookie, signSession, signValue, verifySession, verifyValue };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { importJWK, JWTPayload } from 'jose';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Configuration, read from the environment and nowhere else (F084.4 AC#5).
|
|
5
|
+
*
|
|
6
|
+
* An app mounts this package and sets env vars. It does not pass options in
|
|
7
|
+
* code, because the moment configuration lives in code, two deployments of the
|
|
8
|
+
* same app can disagree about who their identity provider is — and the symptom
|
|
9
|
+
* is a token rejection nobody can trace back to a config line.
|
|
10
|
+
*/
|
|
11
|
+
declare class SsoConfigError extends Error {
|
|
12
|
+
constructor(message: string);
|
|
13
|
+
}
|
|
14
|
+
interface SsoConfig {
|
|
15
|
+
/** Broberg ID's origin, e.g. https://id.broberg.ai — the BARE origin. */
|
|
16
|
+
issuer: string;
|
|
17
|
+
/** The client id this app was registered under (administratively, in BID). */
|
|
18
|
+
clientId: string;
|
|
19
|
+
/** Exactly the redirect registered in BID. Exact match — one character decides. */
|
|
20
|
+
redirectUri: string;
|
|
21
|
+
/** Requested scopes. */
|
|
22
|
+
scopes: string[];
|
|
23
|
+
/** Signs the local session cookie. 32+ bytes of randomness. */
|
|
24
|
+
cookieSecret: string;
|
|
25
|
+
/** Local session cookie name. */
|
|
26
|
+
cookieName: string;
|
|
27
|
+
/**
|
|
28
|
+
* How long this app trusts its OWN session, in seconds.
|
|
29
|
+
*
|
|
30
|
+
* F084.7 decided the fleet's numbers: 7 days for a normal app, and 12 hours
|
|
31
|
+
* + 30 minutes of inactivity for anything holding personal or health data.
|
|
32
|
+
* The default here is the 7 days. An app handling patient data MUST set
|
|
33
|
+
* SSO_SESSION_MAX_AGE=43200 — the default is a normal-app default, not a
|
|
34
|
+
* safe-for-everything one.
|
|
35
|
+
*/
|
|
36
|
+
sessionMaxAge: number;
|
|
37
|
+
/** Where to send the browser after a logout completes. */
|
|
38
|
+
postLogoutRedirectUri?: string;
|
|
39
|
+
}
|
|
40
|
+
/** Seconds in a week — the fleet default from F084.7. */
|
|
41
|
+
declare const DEFAULT_SESSION_MAX_AGE: number;
|
|
42
|
+
declare function loadSsoConfig(env?: NodeJS.ProcessEnv): SsoConfig;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The signing-key cache.
|
|
46
|
+
*
|
|
47
|
+
* ── THE ONE BEHAVIOUR THIS FILE EXISTS FOR ────────────────────────────────
|
|
48
|
+
*
|
|
49
|
+
* It refetches on an UNKNOWN KEY ID, not on a timer (F084.4's constraint, and
|
|
50
|
+
* it is the right one). An interval is a guess about when somebody else will
|
|
51
|
+
* rotate their key; an unknown kid is the event itself. With an interval, the
|
|
52
|
+
* window between "BID rotated" and "the interval elapsed" is a window where
|
|
53
|
+
* every login in every app fails, and the length of that window is a number
|
|
54
|
+
* nobody chose on purpose.
|
|
55
|
+
*
|
|
56
|
+
* ── AND THE PART AN OBVIOUS IMPLEMENTATION GETS WRONG ─────────────────────
|
|
57
|
+
*
|
|
58
|
+
* "Unknown kid ⇒ refetch" turns anyone who can send this app a token into
|
|
59
|
+
* someone who can make it hammer BID: a stream of tokens with random kids is a
|
|
60
|
+
* stream of fetches against the one service the whole fleet logs in through.
|
|
61
|
+
* So a refetch is rate-limited by time. The cost of the floor is real and worth
|
|
62
|
+
* stating: a rotation landing inside the cooldown makes logins fail for up to
|
|
63
|
+
* that many milliseconds. Seconds of failure for one app beats a way to aim
|
|
64
|
+
* traffic at BID from outside.
|
|
65
|
+
*/
|
|
66
|
+
|
|
67
|
+
type SigningKey = Awaited<ReturnType<typeof importJWK>>;
|
|
68
|
+
interface JwksCacheOptions {
|
|
69
|
+
/** Absolute URL of the key set, taken from BID's discovery document. */
|
|
70
|
+
jwksUri: string;
|
|
71
|
+
/**
|
|
72
|
+
* The floor between two refetches. Below it, an unknown kid is rejected
|
|
73
|
+
* without asking BID again.
|
|
74
|
+
*/
|
|
75
|
+
minRefetchIntervalMs?: number;
|
|
76
|
+
/** Injectable for tests. */
|
|
77
|
+
fetchImpl?: typeof fetch;
|
|
78
|
+
/** Injectable for tests, so the cooldown can be exercised without waiting. */
|
|
79
|
+
now?: () => number;
|
|
80
|
+
}
|
|
81
|
+
interface JwksCache {
|
|
82
|
+
/** Resolve a key for this kid, refetching once if it is unknown. */
|
|
83
|
+
getKey(kid: string, alg: string): Promise<SigningKey>;
|
|
84
|
+
/** How many times the remote key set has actually been fetched. */
|
|
85
|
+
readonly fetchCount: number;
|
|
86
|
+
}
|
|
87
|
+
declare class JwksError extends Error {
|
|
88
|
+
constructor(message: string);
|
|
89
|
+
}
|
|
90
|
+
declare function createJwksCache(options: JwksCacheOptions): JwksCache;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The core: framework-free, public-client OAuth 2.1 against Broberg ID.
|
|
94
|
+
*
|
|
95
|
+
* ── THIS PACKAGE IS A PUBLIC CLIENT. THERE IS NO CLIENT SECRET. ───────────
|
|
96
|
+
*
|
|
97
|
+
* Deliberate, and F084.4 AC#4 greps the published tarball to keep it that way.
|
|
98
|
+
* PKCE alone carries the exchange. That is safe here for a reason worth stating
|
|
99
|
+
* rather than assuming: BID matches redirect addresses EXACTLY (measured — one
|
|
100
|
+
* trailing slash is refused), so the authorization code is delivered to this
|
|
101
|
+
* app's own server and nowhere else. An attacker who knows the client id can
|
|
102
|
+
* start a flow; they cannot receive its result.
|
|
103
|
+
*
|
|
104
|
+
* What it buys is the thing the card actually asks for: a secret that does not
|
|
105
|
+
* exist cannot be committed, leaked in a log, copied into a second app, or left
|
|
106
|
+
* behind in a repository someone later makes public.
|
|
107
|
+
*
|
|
108
|
+
* ── AND WHAT THIS PACKAGE MUST NEVER LEARN TO DO ──────────────────────────
|
|
109
|
+
*
|
|
110
|
+
* No passwords. No passkey registration. No social-provider keys. No email
|
|
111
|
+
* verification. All of it lives in BID. A client that CAN do any of it is a
|
|
112
|
+
* client somebody eventually uses to do it — and then the identity rules exist
|
|
113
|
+
* in two places and drift.
|
|
114
|
+
*/
|
|
115
|
+
|
|
116
|
+
interface Discovery {
|
|
117
|
+
issuer: string;
|
|
118
|
+
authorization_endpoint: string;
|
|
119
|
+
token_endpoint: string;
|
|
120
|
+
jwks_uri: string;
|
|
121
|
+
userinfo_endpoint?: string;
|
|
122
|
+
end_session_endpoint?: string;
|
|
123
|
+
}
|
|
124
|
+
declare class SsoError extends Error {
|
|
125
|
+
constructor(message: string);
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* What `beginLogin` produces. Every field except `url` must survive the round
|
|
129
|
+
* trip to BID and back — the adapter stores them in a short-lived cookie.
|
|
130
|
+
*
|
|
131
|
+
* They are NOT optional extras. `state` is what makes the callback provably the
|
|
132
|
+
* answer to THIS request, and `nonce` is what stops a valid token minted for
|
|
133
|
+
* some other login from being replayed into this one.
|
|
134
|
+
*/
|
|
135
|
+
interface LoginStart {
|
|
136
|
+
url: string;
|
|
137
|
+
state: string;
|
|
138
|
+
codeVerifier: string;
|
|
139
|
+
nonce: string;
|
|
140
|
+
}
|
|
141
|
+
interface SsoClaims extends JWTPayload {
|
|
142
|
+
sub: string;
|
|
143
|
+
email?: string;
|
|
144
|
+
name?: string;
|
|
145
|
+
picture?: string;
|
|
146
|
+
email_verified?: boolean;
|
|
147
|
+
}
|
|
148
|
+
interface LoginResult {
|
|
149
|
+
claims: SsoClaims;
|
|
150
|
+
idToken: string;
|
|
151
|
+
accessToken?: string;
|
|
152
|
+
refreshToken?: string;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* `prompt=none` asks BID to answer WITHOUT showing anything (F084.6).
|
|
156
|
+
*
|
|
157
|
+
* The delivery method decides whether it works, and this package only ever
|
|
158
|
+
* produces a URL for a FULL TOP-LEVEL REDIRECT. It never returns anything an
|
|
159
|
+
* app could put in a hidden iframe, because an iframe against the identity
|
|
160
|
+
* provider is third-party context: Safari has blocked it for years and Chrome
|
|
161
|
+
* is retiring it. That path works in testing on a Mac and fails for every user
|
|
162
|
+
* on an iPhone — a failure that looks like "you are not logged in".
|
|
163
|
+
*/
|
|
164
|
+
interface BeginLoginOptions {
|
|
165
|
+
prompt?: "none" | "login" | "consent" | "select_account";
|
|
166
|
+
/** Extra scopes for this one request, on top of the configured set. */
|
|
167
|
+
scopes?: string[];
|
|
168
|
+
}
|
|
169
|
+
interface SsoClient {
|
|
170
|
+
discovery(): Promise<Discovery>;
|
|
171
|
+
beginLogin(options?: BeginLoginOptions): Promise<LoginStart>;
|
|
172
|
+
completeLogin(input: {
|
|
173
|
+
params: URLSearchParams;
|
|
174
|
+
state: string;
|
|
175
|
+
codeVerifier: string;
|
|
176
|
+
nonce: string;
|
|
177
|
+
}): Promise<LoginResult>;
|
|
178
|
+
verifyIdToken(idToken: string, options?: {
|
|
179
|
+
nonce?: string;
|
|
180
|
+
}): Promise<SsoClaims>;
|
|
181
|
+
logoutUrl(options?: {
|
|
182
|
+
idTokenHint?: string;
|
|
183
|
+
postLogoutRedirectUri?: string;
|
|
184
|
+
}): Promise<string>;
|
|
185
|
+
/** Exposed for tests and for a health check; not needed in normal use. */
|
|
186
|
+
readonly jwks: JwksCache;
|
|
187
|
+
}
|
|
188
|
+
interface CreateSsoClientOptions {
|
|
189
|
+
fetchImpl?: typeof fetch;
|
|
190
|
+
/** Passed through to the key cache; see jwks.ts for why there is a floor. */
|
|
191
|
+
minRefetchIntervalMs?: number;
|
|
192
|
+
}
|
|
193
|
+
declare function createSsoClient(config: SsoConfig, options?: CreateSsoClientOptions): SsoClient;
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* The app's OWN session cookie — signed, not encrypted.
|
|
197
|
+
*
|
|
198
|
+
* Signed is the right choice and the distinction matters: the contents are not
|
|
199
|
+
* secret (a user may read their own id and name), but they must not be
|
|
200
|
+
* FORGEABLE. Encryption would hide a subject id the user already knows while
|
|
201
|
+
* doing nothing extra about forgery, which is the actual risk.
|
|
202
|
+
*
|
|
203
|
+
* What goes in is deliberately small: who you are and when this stops being
|
|
204
|
+
* true. Claims that can change — a name, a role, a picture — belong in the
|
|
205
|
+
* app's own store keyed by `sub`, because a cookie is a cache nobody can
|
|
206
|
+
* invalidate, and a stale role in a cookie is a permission that outlives its
|
|
207
|
+
* revocation.
|
|
208
|
+
*/
|
|
209
|
+
interface SessionPayload {
|
|
210
|
+
/** The subject from Broberg ID. The one stable identifier. */
|
|
211
|
+
sub: string;
|
|
212
|
+
/** Unix seconds. Checked on every read. */
|
|
213
|
+
exp: number;
|
|
214
|
+
/** Optional convenience copies; never authorisation data. */
|
|
215
|
+
email?: string;
|
|
216
|
+
name?: string;
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Sign an arbitrary string. The primitive underneath BOTH cookies this package
|
|
220
|
+
* sets — the session and the short-lived login transaction.
|
|
221
|
+
*
|
|
222
|
+
* They are separate functions on purpose. The first version of this file had
|
|
223
|
+
* the transaction ride inside the session envelope with `exp: 0`, and the
|
|
224
|
+
* expiry check (`exp * 1000 <= now()`) then rejected it every single time: the
|
|
225
|
+
* transaction cookie could never be read back, so every login would have failed
|
|
226
|
+
* with "state does not match" — a message pointing at the wrong thing entirely.
|
|
227
|
+
* Two different lifetimes wanted two different envelopes, not one envelope with
|
|
228
|
+
* a sentinel in it.
|
|
229
|
+
*/
|
|
230
|
+
declare function signValue(value: string, secret: string): Promise<string>;
|
|
231
|
+
/** Verify the SIGNATURE only, returning the original string. No expiry notion. */
|
|
232
|
+
declare function verifyValue(token: string | undefined | null, secret: string): Promise<string | null>;
|
|
233
|
+
declare function signSession(payload: SessionPayload, secret: string): Promise<string>;
|
|
234
|
+
/**
|
|
235
|
+
* Returns null for ANY reason the cookie cannot be trusted — tampered,
|
|
236
|
+
* truncated, wrong secret, expired, or simply not one of ours.
|
|
237
|
+
*
|
|
238
|
+
* Deliberately one return value rather than distinguishing them to the caller:
|
|
239
|
+
* an app that can tell "bad signature" from "expired" will eventually branch on
|
|
240
|
+
* it, and there is no branch where a forged cookie should do anything other
|
|
241
|
+
* than what an absent one does.
|
|
242
|
+
*/
|
|
243
|
+
declare function verifySession(token: string | undefined | null, secret: string, now?: () => number): Promise<SessionPayload | null>;
|
|
244
|
+
/** Serialise a Set-Cookie value. `secure` is off only for http://localhost. */
|
|
245
|
+
declare function cookieHeader(name: string, value: string, opts: {
|
|
246
|
+
maxAge: number;
|
|
247
|
+
secure: boolean;
|
|
248
|
+
sameSite?: "Lax" | "Strict";
|
|
249
|
+
path?: string;
|
|
250
|
+
}): string;
|
|
251
|
+
declare function readCookie(header: string | null | undefined, name: string): string | undefined;
|
|
252
|
+
|
|
253
|
+
export { type BeginLoginOptions, type CreateSsoClientOptions, DEFAULT_SESSION_MAX_AGE, type Discovery, type JwksCache, type JwksCacheOptions, JwksError, type LoginResult, type LoginStart, type SessionPayload, type SsoClaims, type SsoClient, type SsoConfig, SsoConfigError, SsoError, cookieHeader, createJwksCache, createSsoClient, loadSsoConfig, readCookie, signSession, signValue, verifySession, verifyValue };
|