@broberg/sso 0.7.0 → 0.9.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 +55 -1
- package/dist/hono.cjs +125 -8
- package/dist/hono.cjs.map +1 -1
- package/dist/hono.d.cts +72 -0
- package/dist/hono.d.ts +72 -0
- package/dist/hono.js +125 -8
- package/dist/hono.js.map +1 -1
- package/dist/index.cjs +67 -5
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +77 -1
- package/dist/index.d.ts +77 -1
- package/dist/index.js +67 -6
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
package/dist/index.d.cts
CHANGED
|
@@ -224,9 +224,23 @@ interface SsoClaims extends JWTPayload {
|
|
|
224
224
|
}
|
|
225
225
|
interface LoginResult {
|
|
226
226
|
claims: SsoClaims;
|
|
227
|
+
/**
|
|
228
|
+
* The claims in `claims` that came from the UNSIGNED userinfo response and
|
|
229
|
+
* not from the signed ID token (components-F084.51, since 0.8.0). Sorted; an
|
|
230
|
+
* empty list means everything in `claims` was signed (or userinfo was not
|
|
231
|
+
* reached). When both sources carry a claim, the signed token's value wins
|
|
232
|
+
* and the claim is not listed.
|
|
233
|
+
*
|
|
234
|
+
* On BID today this always contains "email" and "email_verified": the ID
|
|
235
|
+
* token does not carry them (measured by broberg-id, 20 Sep 2026). A decision
|
|
236
|
+
* that must rest on a signed identity binds on `claims.sub`, never on email.
|
|
237
|
+
*/
|
|
238
|
+
unverifiedClaims: string[];
|
|
227
239
|
idToken: string;
|
|
228
240
|
accessToken?: string;
|
|
229
241
|
refreshToken?: string;
|
|
242
|
+
/** Seconds the access token lives, when BID said so (`expires_in`). */
|
|
243
|
+
expiresIn?: number;
|
|
230
244
|
}
|
|
231
245
|
/**
|
|
232
246
|
* `prompt=none` asks BID to answer WITHOUT showing anything (components-F084.6).
|
|
@@ -266,6 +280,16 @@ interface SsoClient {
|
|
|
266
280
|
* answers — see the implementation for why that must never become "unverified".
|
|
267
281
|
*/
|
|
268
282
|
addressOwnership(accessToken: string, address: string): Promise<AddressOwnership>;
|
|
283
|
+
/**
|
|
284
|
+
* F095.1 — trade a refresh token for a new access token (`grant_type=refresh_token`).
|
|
285
|
+
* Throws SsoError on refusal; the message carries the status and BID's error
|
|
286
|
+
* code, never a token.
|
|
287
|
+
*/
|
|
288
|
+
refreshTokens(refreshToken: string): Promise<{
|
|
289
|
+
accessToken: string;
|
|
290
|
+
refreshToken?: string;
|
|
291
|
+
expiresIn?: number;
|
|
292
|
+
}>;
|
|
269
293
|
/**
|
|
270
294
|
* Invite users to BID on behalf of this app (the app's OWN key, `bidk_…`).
|
|
271
295
|
* One result per email. Throws SsoError on anything BID did not clearly say.
|
|
@@ -359,6 +383,12 @@ interface SessionPayload {
|
|
|
359
383
|
* older than any logout.
|
|
360
384
|
*/
|
|
361
385
|
iat?: number;
|
|
386
|
+
/**
|
|
387
|
+
* F095.1 — the key to this session's server-side tokens (ssoRoutes'
|
|
388
|
+
* `tokenStore`). Random, 128 bits. The tokens themselves are NEVER in the
|
|
389
|
+
* cookie: it is signed, not encrypted, and the browser can read it.
|
|
390
|
+
*/
|
|
391
|
+
sid?: string;
|
|
362
392
|
/** Optional convenience copies; never authorisation data. */
|
|
363
393
|
email?: string;
|
|
364
394
|
name?: string;
|
|
@@ -437,4 +467,50 @@ declare function cookieHeader(name: string, value: string, opts: {
|
|
|
437
467
|
}): string;
|
|
438
468
|
declare function readCookie(header: string | null | undefined, name: string): string | undefined;
|
|
439
469
|
|
|
440
|
-
|
|
470
|
+
/**
|
|
471
|
+
* Server-side tokens for calling Broberg ID on the user's behalf (F095.1).
|
|
472
|
+
*
|
|
473
|
+
* The login already RECEIVES an access token and a refresh token; before 0.9.0
|
|
474
|
+
* ssoRoutes dropped both, so an app could not call BID's app API (the account
|
|
475
|
+
* page: read and change your own name and picture) as the user.
|
|
476
|
+
*
|
|
477
|
+
* ── WHY NOT IN THE COOKIE ────────────────────────────────────────────────
|
|
478
|
+
* The session cookie is SIGNED, not encrypted, and the browser can read it. A
|
|
479
|
+
* refresh token is the longest-lived, highest-value credential in the whole
|
|
480
|
+
* flow: whoever holds it can mint access tokens until it is revoked. So the
|
|
481
|
+
* tokens live server-side, keyed by a random `sid` that is the only thing the
|
|
482
|
+
* cookie carries. A stolen cookie still needs the server to be useful.
|
|
483
|
+
*/
|
|
484
|
+
interface TokenSet {
|
|
485
|
+
/** The BID subject the tokens belong to — lets «Log ud overalt» find them. */
|
|
486
|
+
sub: string;
|
|
487
|
+
accessToken: string;
|
|
488
|
+
refreshToken?: string;
|
|
489
|
+
/** Unix seconds. */
|
|
490
|
+
expiresAt: number;
|
|
491
|
+
}
|
|
492
|
+
/**
|
|
493
|
+
* Where the tokens live. The APP provides it in production, backed by
|
|
494
|
+
* something every instance shares (SQLite on one machine, a database or Redis
|
|
495
|
+
* on several) — the same reason BackchannelStore has no in-memory default.
|
|
496
|
+
*/
|
|
497
|
+
interface TokenStore {
|
|
498
|
+
get(sid: string): Promise<TokenSet | undefined>;
|
|
499
|
+
set(sid: string, tokens: TokenSet): Promise<void>;
|
|
500
|
+
delete(sid: string): Promise<void>;
|
|
501
|
+
/** Drop every token set for `sub` — what «Log ud overalt» calls. */
|
|
502
|
+
deleteSub(sub: string): Promise<void>;
|
|
503
|
+
}
|
|
504
|
+
/**
|
|
505
|
+
* An in-process TokenStore. Fine for tests and a single dev process. In
|
|
506
|
+
* production it LOSES every user's tokens on a restart (they must log in again
|
|
507
|
+
* before the account page works) and is not shared between machines — so it
|
|
508
|
+
* says so, once, when created.
|
|
509
|
+
*/
|
|
510
|
+
declare function memoryTokenStore(options?: {
|
|
511
|
+
quiet?: boolean;
|
|
512
|
+
}): TokenStore & {
|
|
513
|
+
readonly size: number;
|
|
514
|
+
};
|
|
515
|
+
|
|
516
|
+
export { type AddressOwnership, type BeginLoginOptions, type CreateSsoClientOptions, DEFAULT_SESSION_MAX_AGE, type Discovery, type InvitationOutcome, type InvitationResult, type InviteUsersInput, type JwksCache, type JwksCacheOptions, JwksError, JwksUnavailableError, JwksUnknownKeyError, type LoginResult, type LoginStart, type LogoutToken, MAX_INVITATIONS, type MigrationState, type MigrationStatus, type SessionPayload, type SsoClaims, type SsoClient, type SsoConfig, SsoConfigError, SsoError, type TokenSet, type TokenStore, cookieHeader, createJwksCache, createSsoClient, loadSsoConfig, memoryTokenStore, readCookie, signSession, signValue, verifySession, verifyValue };
|
package/dist/index.d.ts
CHANGED
|
@@ -224,9 +224,23 @@ interface SsoClaims extends JWTPayload {
|
|
|
224
224
|
}
|
|
225
225
|
interface LoginResult {
|
|
226
226
|
claims: SsoClaims;
|
|
227
|
+
/**
|
|
228
|
+
* The claims in `claims` that came from the UNSIGNED userinfo response and
|
|
229
|
+
* not from the signed ID token (components-F084.51, since 0.8.0). Sorted; an
|
|
230
|
+
* empty list means everything in `claims` was signed (or userinfo was not
|
|
231
|
+
* reached). When both sources carry a claim, the signed token's value wins
|
|
232
|
+
* and the claim is not listed.
|
|
233
|
+
*
|
|
234
|
+
* On BID today this always contains "email" and "email_verified": the ID
|
|
235
|
+
* token does not carry them (measured by broberg-id, 20 Sep 2026). A decision
|
|
236
|
+
* that must rest on a signed identity binds on `claims.sub`, never on email.
|
|
237
|
+
*/
|
|
238
|
+
unverifiedClaims: string[];
|
|
227
239
|
idToken: string;
|
|
228
240
|
accessToken?: string;
|
|
229
241
|
refreshToken?: string;
|
|
242
|
+
/** Seconds the access token lives, when BID said so (`expires_in`). */
|
|
243
|
+
expiresIn?: number;
|
|
230
244
|
}
|
|
231
245
|
/**
|
|
232
246
|
* `prompt=none` asks BID to answer WITHOUT showing anything (components-F084.6).
|
|
@@ -266,6 +280,16 @@ interface SsoClient {
|
|
|
266
280
|
* answers — see the implementation for why that must never become "unverified".
|
|
267
281
|
*/
|
|
268
282
|
addressOwnership(accessToken: string, address: string): Promise<AddressOwnership>;
|
|
283
|
+
/**
|
|
284
|
+
* F095.1 — trade a refresh token for a new access token (`grant_type=refresh_token`).
|
|
285
|
+
* Throws SsoError on refusal; the message carries the status and BID's error
|
|
286
|
+
* code, never a token.
|
|
287
|
+
*/
|
|
288
|
+
refreshTokens(refreshToken: string): Promise<{
|
|
289
|
+
accessToken: string;
|
|
290
|
+
refreshToken?: string;
|
|
291
|
+
expiresIn?: number;
|
|
292
|
+
}>;
|
|
269
293
|
/**
|
|
270
294
|
* Invite users to BID on behalf of this app (the app's OWN key, `bidk_…`).
|
|
271
295
|
* One result per email. Throws SsoError on anything BID did not clearly say.
|
|
@@ -359,6 +383,12 @@ interface SessionPayload {
|
|
|
359
383
|
* older than any logout.
|
|
360
384
|
*/
|
|
361
385
|
iat?: number;
|
|
386
|
+
/**
|
|
387
|
+
* F095.1 — the key to this session's server-side tokens (ssoRoutes'
|
|
388
|
+
* `tokenStore`). Random, 128 bits. The tokens themselves are NEVER in the
|
|
389
|
+
* cookie: it is signed, not encrypted, and the browser can read it.
|
|
390
|
+
*/
|
|
391
|
+
sid?: string;
|
|
362
392
|
/** Optional convenience copies; never authorisation data. */
|
|
363
393
|
email?: string;
|
|
364
394
|
name?: string;
|
|
@@ -437,4 +467,50 @@ declare function cookieHeader(name: string, value: string, opts: {
|
|
|
437
467
|
}): string;
|
|
438
468
|
declare function readCookie(header: string | null | undefined, name: string): string | undefined;
|
|
439
469
|
|
|
440
|
-
|
|
470
|
+
/**
|
|
471
|
+
* Server-side tokens for calling Broberg ID on the user's behalf (F095.1).
|
|
472
|
+
*
|
|
473
|
+
* The login already RECEIVES an access token and a refresh token; before 0.9.0
|
|
474
|
+
* ssoRoutes dropped both, so an app could not call BID's app API (the account
|
|
475
|
+
* page: read and change your own name and picture) as the user.
|
|
476
|
+
*
|
|
477
|
+
* ── WHY NOT IN THE COOKIE ────────────────────────────────────────────────
|
|
478
|
+
* The session cookie is SIGNED, not encrypted, and the browser can read it. A
|
|
479
|
+
* refresh token is the longest-lived, highest-value credential in the whole
|
|
480
|
+
* flow: whoever holds it can mint access tokens until it is revoked. So the
|
|
481
|
+
* tokens live server-side, keyed by a random `sid` that is the only thing the
|
|
482
|
+
* cookie carries. A stolen cookie still needs the server to be useful.
|
|
483
|
+
*/
|
|
484
|
+
interface TokenSet {
|
|
485
|
+
/** The BID subject the tokens belong to — lets «Log ud overalt» find them. */
|
|
486
|
+
sub: string;
|
|
487
|
+
accessToken: string;
|
|
488
|
+
refreshToken?: string;
|
|
489
|
+
/** Unix seconds. */
|
|
490
|
+
expiresAt: number;
|
|
491
|
+
}
|
|
492
|
+
/**
|
|
493
|
+
* Where the tokens live. The APP provides it in production, backed by
|
|
494
|
+
* something every instance shares (SQLite on one machine, a database or Redis
|
|
495
|
+
* on several) — the same reason BackchannelStore has no in-memory default.
|
|
496
|
+
*/
|
|
497
|
+
interface TokenStore {
|
|
498
|
+
get(sid: string): Promise<TokenSet | undefined>;
|
|
499
|
+
set(sid: string, tokens: TokenSet): Promise<void>;
|
|
500
|
+
delete(sid: string): Promise<void>;
|
|
501
|
+
/** Drop every token set for `sub` — what «Log ud overalt» calls. */
|
|
502
|
+
deleteSub(sub: string): Promise<void>;
|
|
503
|
+
}
|
|
504
|
+
/**
|
|
505
|
+
* An in-process TokenStore. Fine for tests and a single dev process. In
|
|
506
|
+
* production it LOSES every user's tokens on a restart (they must log in again
|
|
507
|
+
* before the account page works) and is not shared between machines — so it
|
|
508
|
+
* says so, once, when created.
|
|
509
|
+
*/
|
|
510
|
+
declare function memoryTokenStore(options?: {
|
|
511
|
+
quiet?: boolean;
|
|
512
|
+
}): TokenStore & {
|
|
513
|
+
readonly size: number;
|
|
514
|
+
};
|
|
515
|
+
|
|
516
|
+
export { type AddressOwnership, type BeginLoginOptions, type CreateSsoClientOptions, DEFAULT_SESSION_MAX_AGE, type Discovery, type InvitationOutcome, type InvitationResult, type InviteUsersInput, type JwksCache, type JwksCacheOptions, JwksError, JwksUnavailableError, JwksUnknownKeyError, type LoginResult, type LoginStart, type LogoutToken, MAX_INVITATIONS, type MigrationState, type MigrationStatus, type SessionPayload, type SsoClaims, type SsoClient, type SsoConfig, SsoConfigError, SsoError, type TokenSet, type TokenStore, cookieHeader, createJwksCache, createSsoClient, loadSsoConfig, memoryTokenStore, readCookie, signSession, signValue, verifySession, verifyValue };
|
package/dist/index.js
CHANGED
|
@@ -355,18 +355,19 @@ function createSsoClient(config, options = {}) {
|
|
|
355
355
|
}
|
|
356
356
|
async function withUserInfo(claims, accessToken) {
|
|
357
357
|
const doc = await discovery();
|
|
358
|
-
if (!doc.userinfo_endpoint) return claims;
|
|
358
|
+
if (!doc.userinfo_endpoint) return { claims, unverifiedClaims: [] };
|
|
359
359
|
const res = await fetchImpl(doc.userinfo_endpoint, {
|
|
360
360
|
headers: { authorization: `Bearer ${accessToken}` }
|
|
361
361
|
});
|
|
362
|
-
if (!res.ok) return claims;
|
|
362
|
+
if (!res.ok) return { claims, unverifiedClaims: [] };
|
|
363
363
|
const info = await res.json();
|
|
364
364
|
if (info.sub !== claims.sub) {
|
|
365
365
|
throw new SsoError(
|
|
366
366
|
`userinfo describes ${String(info.sub)} but the ID token is for ${claims.sub} \u2014 refusing to merge another user's profile onto this session.`
|
|
367
367
|
);
|
|
368
368
|
}
|
|
369
|
-
|
|
369
|
+
const unverifiedClaims = Object.keys(info).filter((k) => k !== "sub" && info[k] !== void 0 && !(k in claims)).sort();
|
|
370
|
+
return { claims: { ...info, ...claims, sub: claims.sub }, unverifiedClaims };
|
|
370
371
|
}
|
|
371
372
|
return {
|
|
372
373
|
discovery,
|
|
@@ -438,11 +439,42 @@ function createSsoClient(config, options = {}) {
|
|
|
438
439
|
);
|
|
439
440
|
}
|
|
440
441
|
const claims = await verifyIdToken(body.id_token, { nonce });
|
|
442
|
+
const merged = body.access_token ? await withUserInfo(claims, body.access_token) : { claims, unverifiedClaims: [] };
|
|
441
443
|
return {
|
|
442
|
-
claims:
|
|
444
|
+
claims: merged.claims,
|
|
445
|
+
unverifiedClaims: merged.unverifiedClaims,
|
|
443
446
|
idToken: body.id_token,
|
|
444
447
|
...body.access_token ? { accessToken: body.access_token } : {},
|
|
445
|
-
...body.refresh_token ? { refreshToken: body.refresh_token } : {}
|
|
448
|
+
...body.refresh_token ? { refreshToken: body.refresh_token } : {},
|
|
449
|
+
...typeof body.expires_in === "number" ? { expiresIn: body.expires_in } : {}
|
|
450
|
+
};
|
|
451
|
+
},
|
|
452
|
+
async refreshTokens(refreshToken) {
|
|
453
|
+
const { token_endpoint } = await discovery();
|
|
454
|
+
const res = await fetchImpl(token_endpoint, {
|
|
455
|
+
method: "POST",
|
|
456
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
457
|
+
body: new URLSearchParams({
|
|
458
|
+
grant_type: "refresh_token",
|
|
459
|
+
refresh_token: refreshToken,
|
|
460
|
+
client_id: config.clientId,
|
|
461
|
+
...config.clientSecret ? { client_secret: config.clientSecret } : {}
|
|
462
|
+
})
|
|
463
|
+
});
|
|
464
|
+
const raw = await res.text();
|
|
465
|
+
let body;
|
|
466
|
+
try {
|
|
467
|
+
body = raw.trim() === "" ? {} : JSON.parse(raw);
|
|
468
|
+
} catch {
|
|
469
|
+
throw new SsoError(`token refresh failed (${res.status}): the response body is not JSON`);
|
|
470
|
+
}
|
|
471
|
+
if (!res.ok || typeof body.access_token !== "string" || body.access_token === "") {
|
|
472
|
+
throw new SsoError(`token refresh failed (${res.status}): ${body.error ?? "no access_token in response"}`);
|
|
473
|
+
}
|
|
474
|
+
return {
|
|
475
|
+
accessToken: body.access_token,
|
|
476
|
+
...typeof body.refresh_token === "string" && body.refresh_token ? { refreshToken: body.refresh_token } : {},
|
|
477
|
+
...typeof body.expires_in === "number" ? { expiresIn: body.expires_in } : {}
|
|
446
478
|
};
|
|
447
479
|
},
|
|
448
480
|
async logoutUrl(opts = {}) {
|
|
@@ -593,6 +625,35 @@ function readCookie(header, name) {
|
|
|
593
625
|
return void 0;
|
|
594
626
|
}
|
|
595
627
|
|
|
596
|
-
|
|
628
|
+
// src/tokens.ts
|
|
629
|
+
var warned = false;
|
|
630
|
+
function memoryTokenStore(options = {}) {
|
|
631
|
+
if (!options.quiet && !warned) {
|
|
632
|
+
warned = true;
|
|
633
|
+
console.warn(
|
|
634
|
+
"@broberg/sso: memoryTokenStore() keeps tokens in this process only \u2014 they are lost on restart and not shared between instances. Give ssoRoutes a TokenStore backed by your database in production."
|
|
635
|
+
);
|
|
636
|
+
}
|
|
637
|
+
const rows = /* @__PURE__ */ new Map();
|
|
638
|
+
return {
|
|
639
|
+
get size() {
|
|
640
|
+
return rows.size;
|
|
641
|
+
},
|
|
642
|
+
async get(sid) {
|
|
643
|
+
return rows.get(sid);
|
|
644
|
+
},
|
|
645
|
+
async set(sid, tokens) {
|
|
646
|
+
rows.set(sid, { ...tokens });
|
|
647
|
+
},
|
|
648
|
+
async delete(sid) {
|
|
649
|
+
rows.delete(sid);
|
|
650
|
+
},
|
|
651
|
+
async deleteSub(sub) {
|
|
652
|
+
for (const [sid, t] of rows) if (t.sub === sub) rows.delete(sid);
|
|
653
|
+
}
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
export { DEFAULT_SESSION_MAX_AGE, JwksError, JwksUnavailableError, JwksUnknownKeyError, MAX_INVITATIONS, SsoConfigError, SsoError, cookieHeader, createJwksCache, createSsoClient, loadSsoConfig, memoryTokenStore, readCookie, signSession, signValue, verifySession, verifyValue };
|
|
597
658
|
//# sourceMappingURL=index.js.map
|
|
598
659
|
//# sourceMappingURL=index.js.map
|