@idp.global/sdk 18.3.1 → 19.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/changelog.md +27 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts_relyingparty/classes.errors.d.ts +9 -1
- package/dist_ts_relyingparty/classes.errors.js +11 -2
- package/dist_ts_relyingparty/classes.idpoidcprovider.d.ts +10 -0
- package/dist_ts_relyingparty/classes.idpoidcprovider.js +3 -1
- package/dist_ts_relyingparty/classes.relyingpartyroutes.d.ts +6 -0
- package/dist_ts_relyingparty/classes.relyingpartyroutes.js +19 -1
- package/dist_ts_relyingparty/classes.relyingpartysessions.d.ts +8 -3
- package/dist_ts_relyingparty/classes.relyingpartysessions.js +34 -13
- package/dist_ts_relyingparty/classes.rpmodels.d.ts +2 -0
- package/dist_ts_relyingparty/classes.rpmodels.js +9 -2
- package/dist_ts_relyingparty/classes.socketbinding.d.ts +33 -14
- package/dist_ts_relyingparty/classes.socketbinding.js +1 -2
- package/dist_ts_relyingparty/interfaces.d.ts +22 -3
- package/dist_ts_relyingparty/plugins.d.ts +1 -2
- package/dist_ts_relyingparty/plugins.js +2 -3
- package/dist_ts_relyingparty/testing.d.ts +103 -0
- package/dist_ts_relyingparty/testing.js +222 -0
- package/package.json +8 -7
- package/readme.md +74 -5
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts_relyingparty/classes.errors.ts +11 -0
- package/ts_relyingparty/classes.idpoidcprovider.ts +13 -0
- package/ts_relyingparty/classes.relyingpartyroutes.ts +19 -0
- package/ts_relyingparty/classes.relyingpartysessions.ts +37 -13
- package/ts_relyingparty/classes.rpmodels.ts +4 -0
- package/ts_relyingparty/classes.socketbinding.ts +38 -18
- package/ts_relyingparty/interfaces.ts +24 -3
- package/ts_relyingparty/plugins.ts +1 -2
- package/ts_relyingparty/testing.ts +306 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { type IOidcAuthorizationOptions, type IOidcCompletion, type IOidcCompletionOptions, type IOidcOrganizationClaim, type IOidcOrganizationObservation, type IOidcProvider, type IOidcRefreshedTokens, type TOidcTokenHint } from './classes.idpoidcprovider.js';
|
|
2
|
+
/**
|
|
3
|
+
* `@idp.global/sdk/relyingparty/testing`: an in-process idp.global for the
|
|
4
|
+
* tests of an application built on `RelyingPartySessions`. It is a test
|
|
5
|
+
* double and nothing else — no socket, no persistence, no check a real
|
|
6
|
+
* provider makes beyond the ones named here — and belongs in test code only.
|
|
7
|
+
*/
|
|
8
|
+
/** A person at the double, with the organizations idp.global reports for them. */
|
|
9
|
+
export interface IProviderDoublePersona {
|
|
10
|
+
/** the stable `sub` */
|
|
11
|
+
subject: string;
|
|
12
|
+
displayName?: string;
|
|
13
|
+
email?: string;
|
|
14
|
+
/** `email_verified`; `true` when absent, as for an active idp.global account */
|
|
15
|
+
emailVerified?: boolean;
|
|
16
|
+
/** the `organizations` claim, and what introspection answers for each organization */
|
|
17
|
+
organizations?: IOidcOrganizationClaim[];
|
|
18
|
+
}
|
|
19
|
+
export interface IProviderDoubleOptions {
|
|
20
|
+
/** the issuer origin the authorization URLs point at; `https://idp.test` by default */
|
|
21
|
+
issuer?: string;
|
|
22
|
+
/** how long an access token lives, in seconds; 3600 by default, `0` names no lifetime */
|
|
23
|
+
accessTokenLifetimeSeconds?: number;
|
|
24
|
+
/** the clock every expiry reads; tests step it instead of sleeping */
|
|
25
|
+
now?: () => number;
|
|
26
|
+
requestTimeoutSeconds?: number;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* The provider as `IOidcProvider` sees it. A test adds personas, names the one
|
|
30
|
+
* the next login signs in as (`signInNextAs`), follows the authorization URL
|
|
31
|
+
* back with `callbackUrlFor`, and changes what idp.global reports live
|
|
32
|
+
* (`setOrganizationRoles`, `endLoginsOf`). Codes are single use and bound to
|
|
33
|
+
* their login's state; access tokens introspect for their subject while they
|
|
34
|
+
* live; refresh tokens rotate, and a superseded one coming back revokes its
|
|
35
|
+
* whole family, as idp.global's reuse detection does. Every call is recorded,
|
|
36
|
+
* and each step can be made to fail.
|
|
37
|
+
*/
|
|
38
|
+
export declare class RelyingPartyProviderDouble implements IOidcProvider {
|
|
39
|
+
#private;
|
|
40
|
+
readonly requestTimeoutSeconds: number;
|
|
41
|
+
readonly issuer: string;
|
|
42
|
+
accessTokenLifetimeSeconds: number;
|
|
43
|
+
readonly authorizations: IOidcAuthorizationOptions[];
|
|
44
|
+
readonly completions: Array<{
|
|
45
|
+
subject: string;
|
|
46
|
+
state: string;
|
|
47
|
+
}>;
|
|
48
|
+
readonly revocations: Array<{
|
|
49
|
+
token: string;
|
|
50
|
+
hint: TOidcTokenHint;
|
|
51
|
+
}>;
|
|
52
|
+
readonly introspections: Array<{
|
|
53
|
+
accessToken: string;
|
|
54
|
+
subject: string;
|
|
55
|
+
organizationId: string;
|
|
56
|
+
}>;
|
|
57
|
+
readonly refreshes: string[];
|
|
58
|
+
/** when set, the completion of a login fails with it (an `OidcCompletionError` names its stage) */
|
|
59
|
+
completionFailure?: Error;
|
|
60
|
+
/** when set, every introspection fails with it (an `OidcAuthorityUnavailableError` for an unreachable provider) */
|
|
61
|
+
introspectionFailure?: Error;
|
|
62
|
+
/** when set, every refresh fails with it */
|
|
63
|
+
refreshFailure?: Error;
|
|
64
|
+
/** when set, every revocation fails with it, after it was recorded */
|
|
65
|
+
revocationFailure?: Error;
|
|
66
|
+
/** runs inside an introspection before it answers: a test holds one open with it */
|
|
67
|
+
onIntrospect?: (optionsArg: {
|
|
68
|
+
accessToken: string;
|
|
69
|
+
subject: string;
|
|
70
|
+
organizationId: string;
|
|
71
|
+
}) => Promise<void>;
|
|
72
|
+
/** runs inside a refresh before it answers: a test races a logout with it */
|
|
73
|
+
onRefresh?: (refreshTokenArg: string) => Promise<void>;
|
|
74
|
+
constructor(optionsArg?: IProviderDoubleOptions);
|
|
75
|
+
/** Adds a persona, or replaces the one with its subject. */
|
|
76
|
+
addPersona(personaArg: IProviderDoublePersona): void;
|
|
77
|
+
persona(subjectArg: string): IProviderDoublePersona | undefined;
|
|
78
|
+
/** The persona the next authorization signs in as; a persona given whole is added first. */
|
|
79
|
+
signInNextAs(personaArg: string | IProviderDoublePersona): void;
|
|
80
|
+
/**
|
|
81
|
+
* What idp.global reports for the persona in an organization from now on:
|
|
82
|
+
* its roles, or `null` when the persona is no member any more. Introspection
|
|
83
|
+
* reads it at once; the claims of a login are those of its sign-in.
|
|
84
|
+
*/
|
|
85
|
+
setOrganizationRoles(subjectArg: string, organizationIdArg: string, rolesArg: string[] | null): void;
|
|
86
|
+
/** Ends every login of the persona at the provider: its tokens neither introspect nor rotate any more. */
|
|
87
|
+
endLoginsOf(subjectArg: string): void;
|
|
88
|
+
/** Where the provider would send the browser back after the login this authorization URL started. */
|
|
89
|
+
callbackUrlFor(authorizationUrlArg: URL, redirectUriArg: URL | string): URL;
|
|
90
|
+
init(): Promise<void>;
|
|
91
|
+
buildAuthorizationUrl(optionsArg: IOidcAuthorizationOptions): Promise<URL>;
|
|
92
|
+
completeAuthorization(optionsArg: IOidcCompletionOptions): Promise<IOidcCompletion>;
|
|
93
|
+
revokeToken(tokenArg: string, hintArg: TOidcTokenHint): Promise<void>;
|
|
94
|
+
introspectOrganization(optionsArg: {
|
|
95
|
+
accessToken: string;
|
|
96
|
+
subject: string;
|
|
97
|
+
organizationId: string;
|
|
98
|
+
}): Promise<IOidcOrganizationObservation>;
|
|
99
|
+
refreshTokens(refreshTokenArg: string): Promise<IOidcRefreshedTokens>;
|
|
100
|
+
private requirePersona;
|
|
101
|
+
private issue;
|
|
102
|
+
private isLive;
|
|
103
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import * as plugins from './plugins.js';
|
|
2
|
+
import { OidcCompletionError, OidcRefreshRefusedError, } from './classes.idpoidcprovider.js';
|
|
3
|
+
const randomToken = () => plugins.crypto.randomBytes(18).toString('base64url');
|
|
4
|
+
/**
|
|
5
|
+
* The provider as `IOidcProvider` sees it. A test adds personas, names the one
|
|
6
|
+
* the next login signs in as (`signInNextAs`), follows the authorization URL
|
|
7
|
+
* back with `callbackUrlFor`, and changes what idp.global reports live
|
|
8
|
+
* (`setOrganizationRoles`, `endLoginsOf`). Codes are single use and bound to
|
|
9
|
+
* their login's state; access tokens introspect for their subject while they
|
|
10
|
+
* live; refresh tokens rotate, and a superseded one coming back revokes its
|
|
11
|
+
* whole family, as idp.global's reuse detection does. Every call is recorded,
|
|
12
|
+
* and each step can be made to fail.
|
|
13
|
+
*/
|
|
14
|
+
export class RelyingPartyProviderDouble {
|
|
15
|
+
requestTimeoutSeconds;
|
|
16
|
+
issuer;
|
|
17
|
+
accessTokenLifetimeSeconds;
|
|
18
|
+
authorizations = [];
|
|
19
|
+
completions = [];
|
|
20
|
+
revocations = [];
|
|
21
|
+
introspections = [];
|
|
22
|
+
refreshes = [];
|
|
23
|
+
/** when set, the completion of a login fails with it (an `OidcCompletionError` names its stage) */
|
|
24
|
+
completionFailure;
|
|
25
|
+
/** when set, every introspection fails with it (an `OidcAuthorityUnavailableError` for an unreachable provider) */
|
|
26
|
+
introspectionFailure;
|
|
27
|
+
/** when set, every refresh fails with it */
|
|
28
|
+
refreshFailure;
|
|
29
|
+
/** when set, every revocation fails with it, after it was recorded */
|
|
30
|
+
revocationFailure;
|
|
31
|
+
/** runs inside an introspection before it answers: a test holds one open with it */
|
|
32
|
+
onIntrospect;
|
|
33
|
+
/** runs inside a refresh before it answers: a test races a logout with it */
|
|
34
|
+
onRefresh;
|
|
35
|
+
#now;
|
|
36
|
+
#personas = new Map();
|
|
37
|
+
#codes = new Map();
|
|
38
|
+
#tokens = new Map();
|
|
39
|
+
#next;
|
|
40
|
+
constructor(optionsArg = {}) {
|
|
41
|
+
this.issuer = new URL(optionsArg.issuer ?? 'https://idp.test').origin;
|
|
42
|
+
this.accessTokenLifetimeSeconds = optionsArg.accessTokenLifetimeSeconds ?? 3_600;
|
|
43
|
+
this.requestTimeoutSeconds = optionsArg.requestTimeoutSeconds ?? 10;
|
|
44
|
+
this.#now = optionsArg.now ?? (() => Date.now());
|
|
45
|
+
}
|
|
46
|
+
// ---- personas ----
|
|
47
|
+
/** Adds a persona, or replaces the one with its subject. */
|
|
48
|
+
addPersona(personaArg) {
|
|
49
|
+
this.#personas.set(personaArg.subject, {
|
|
50
|
+
...personaArg,
|
|
51
|
+
organizations: (personaArg.organizations ?? []).map((entryArg) => ({ ...entryArg, roles: [...entryArg.roles] })),
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
persona(subjectArg) {
|
|
55
|
+
return this.#personas.get(subjectArg);
|
|
56
|
+
}
|
|
57
|
+
/** The persona the next authorization signs in as; a persona given whole is added first. */
|
|
58
|
+
signInNextAs(personaArg) {
|
|
59
|
+
if (typeof personaArg !== 'string') {
|
|
60
|
+
this.addPersona(personaArg);
|
|
61
|
+
}
|
|
62
|
+
const subject = typeof personaArg === 'string' ? personaArg : personaArg.subject;
|
|
63
|
+
if (!this.#personas.has(subject)) {
|
|
64
|
+
throw new Error(`RelyingPartyProviderDouble: no persona ${subject}; addPersona first`);
|
|
65
|
+
}
|
|
66
|
+
this.#next = subject;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* What idp.global reports for the persona in an organization from now on:
|
|
70
|
+
* its roles, or `null` when the persona is no member any more. Introspection
|
|
71
|
+
* reads it at once; the claims of a login are those of its sign-in.
|
|
72
|
+
*/
|
|
73
|
+
setOrganizationRoles(subjectArg, organizationIdArg, rolesArg) {
|
|
74
|
+
const persona = this.requirePersona(subjectArg);
|
|
75
|
+
const organizations = (persona.organizations ?? []).filter((entryArg) => entryArg.id !== organizationIdArg);
|
|
76
|
+
if (rolesArg) {
|
|
77
|
+
const known = persona.organizations?.find((entryArg) => entryArg.id === organizationIdArg);
|
|
78
|
+
organizations.push({
|
|
79
|
+
id: organizationIdArg,
|
|
80
|
+
name: known?.name ?? organizationIdArg,
|
|
81
|
+
slug: known?.slug ?? organizationIdArg,
|
|
82
|
+
roles: [...rolesArg],
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
persona.organizations = organizations;
|
|
86
|
+
}
|
|
87
|
+
/** Ends every login of the persona at the provider: its tokens neither introspect nor rotate any more. */
|
|
88
|
+
endLoginsOf(subjectArg) {
|
|
89
|
+
for (const token of this.#tokens.values()) {
|
|
90
|
+
if (token.subject === subjectArg) {
|
|
91
|
+
token.revoked = true;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/** Where the provider would send the browser back after the login this authorization URL started. */
|
|
96
|
+
callbackUrlFor(authorizationUrlArg, redirectUriArg) {
|
|
97
|
+
const callback = new URL(redirectUriArg);
|
|
98
|
+
callback.searchParams.set('code', authorizationUrlArg.searchParams.get('code') ?? '');
|
|
99
|
+
callback.searchParams.set('state', authorizationUrlArg.searchParams.get('state') ?? '');
|
|
100
|
+
return callback;
|
|
101
|
+
}
|
|
102
|
+
// ---- IOidcProvider ----
|
|
103
|
+
async init() { }
|
|
104
|
+
async buildAuthorizationUrl(optionsArg) {
|
|
105
|
+
if (!this.#next) {
|
|
106
|
+
throw new Error('RelyingPartyProviderDouble: name the persona this login signs in as with signInNextAs');
|
|
107
|
+
}
|
|
108
|
+
this.authorizations.push({ ...optionsArg });
|
|
109
|
+
const code = 'code_' + randomToken();
|
|
110
|
+
this.#codes.set(code, { state: optionsArg.state, nonce: optionsArg.nonce, subject: this.#next });
|
|
111
|
+
const url = new URL('/oauth/authorize', this.issuer);
|
|
112
|
+
url.searchParams.set('response_type', 'code');
|
|
113
|
+
url.searchParams.set('state', optionsArg.state);
|
|
114
|
+
url.searchParams.set('nonce', optionsArg.nonce);
|
|
115
|
+
if (optionsArg.prompt) {
|
|
116
|
+
url.searchParams.set('prompt', optionsArg.prompt);
|
|
117
|
+
}
|
|
118
|
+
// what the double hands back at once; a real provider answers it after the login page
|
|
119
|
+
url.searchParams.set('code', code);
|
|
120
|
+
return url;
|
|
121
|
+
}
|
|
122
|
+
async completeAuthorization(optionsArg) {
|
|
123
|
+
const code = optionsArg.callbackUrl.searchParams.get('code') ?? '';
|
|
124
|
+
const issued = this.#codes.get(code);
|
|
125
|
+
this.#codes.delete(code);
|
|
126
|
+
if (!issued || issued.state !== optionsArg.state) {
|
|
127
|
+
throw new OidcCompletionError('token_exchange', new Error('unknown, used or foreign code'));
|
|
128
|
+
}
|
|
129
|
+
if (issued.nonce !== optionsArg.nonce) {
|
|
130
|
+
throw new OidcCompletionError('token_validation', new Error('nonce mismatch'));
|
|
131
|
+
}
|
|
132
|
+
this.completions.push({ subject: issued.subject, state: issued.state });
|
|
133
|
+
if (this.completionFailure) {
|
|
134
|
+
throw this.completionFailure;
|
|
135
|
+
}
|
|
136
|
+
const persona = this.requirePersona(issued.subject);
|
|
137
|
+
const family = randomToken();
|
|
138
|
+
const access = this.issue('access', issued.subject, family);
|
|
139
|
+
const refresh = this.issue('refresh', issued.subject, family);
|
|
140
|
+
return {
|
|
141
|
+
subject: persona.subject,
|
|
142
|
+
accessToken: access.token,
|
|
143
|
+
refreshToken: refresh.token,
|
|
144
|
+
accessTokenExpiresAt: access.expiresAt,
|
|
145
|
+
...(persona.email !== undefined ? { email: persona.email } : {}),
|
|
146
|
+
emailVerified: persona.emailVerified ?? true,
|
|
147
|
+
...(persona.displayName !== undefined ? { displayName: persona.displayName } : {}),
|
|
148
|
+
organizations: (persona.organizations ?? []).map((entryArg) => ({ ...entryArg, roles: [...entryArg.roles] })),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
async revokeToken(tokenArg, hintArg) {
|
|
152
|
+
this.revocations.push({ token: tokenArg, hint: hintArg });
|
|
153
|
+
if (this.revocationFailure) {
|
|
154
|
+
throw this.revocationFailure;
|
|
155
|
+
}
|
|
156
|
+
const token = this.#tokens.get(tokenArg);
|
|
157
|
+
if (token) {
|
|
158
|
+
token.revoked = true;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
async introspectOrganization(optionsArg) {
|
|
162
|
+
this.introspections.push({ ...optionsArg });
|
|
163
|
+
await this.onIntrospect?.({ ...optionsArg });
|
|
164
|
+
if (this.introspectionFailure) {
|
|
165
|
+
throw this.introspectionFailure;
|
|
166
|
+
}
|
|
167
|
+
const token = this.#tokens.get(optionsArg.accessToken);
|
|
168
|
+
const inactive = { active: false, roles: [], expiresAt: 0 };
|
|
169
|
+
if (!token || token.kind !== 'access' || !this.isLive(token) || token.subject !== optionsArg.subject) {
|
|
170
|
+
return inactive;
|
|
171
|
+
}
|
|
172
|
+
const membership = this.#personas.get(token.subject)?.organizations?.find((entryArg) => entryArg.id === optionsArg.organizationId);
|
|
173
|
+
if (!membership) {
|
|
174
|
+
return inactive;
|
|
175
|
+
}
|
|
176
|
+
return { active: true, roles: [...membership.roles].sort(), expiresAt: token.expiresAt };
|
|
177
|
+
}
|
|
178
|
+
async refreshTokens(refreshTokenArg) {
|
|
179
|
+
this.refreshes.push(refreshTokenArg);
|
|
180
|
+
await this.onRefresh?.(refreshTokenArg);
|
|
181
|
+
if (this.refreshFailure) {
|
|
182
|
+
throw this.refreshFailure;
|
|
183
|
+
}
|
|
184
|
+
const token = this.#tokens.get(refreshTokenArg);
|
|
185
|
+
if (!token || token.kind !== 'refresh' || token.revoked) {
|
|
186
|
+
throw new OidcRefreshRefusedError(new Error('invalid_grant'));
|
|
187
|
+
}
|
|
188
|
+
if (token.used) {
|
|
189
|
+
// reuse of a superseded refresh token: the family dies
|
|
190
|
+
for (const entry of this.#tokens.values()) {
|
|
191
|
+
if (entry.family === token.family) {
|
|
192
|
+
entry.revoked = true;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
throw new OidcRefreshRefusedError(new Error('invalid_grant'));
|
|
196
|
+
}
|
|
197
|
+
token.used = true;
|
|
198
|
+
const access = this.issue('access', token.subject, token.family);
|
|
199
|
+
const refresh = this.issue('refresh', token.subject, token.family);
|
|
200
|
+
return { accessToken: access.token, refreshToken: refresh.token, accessTokenExpiresAt: access.expiresAt };
|
|
201
|
+
}
|
|
202
|
+
// ---- internals ----
|
|
203
|
+
requirePersona(subjectArg) {
|
|
204
|
+
const persona = this.#personas.get(subjectArg);
|
|
205
|
+
if (!persona) {
|
|
206
|
+
throw new Error(`RelyingPartyProviderDouble: no persona ${subjectArg}`);
|
|
207
|
+
}
|
|
208
|
+
return persona;
|
|
209
|
+
}
|
|
210
|
+
issue(kindArg, subjectArg, familyArg) {
|
|
211
|
+
const token = (kindArg === 'access' ? 'at_' : 'rt_') + randomToken();
|
|
212
|
+
const expiresAt = kindArg === 'access' && this.accessTokenLifetimeSeconds > 0
|
|
213
|
+
? this.#now() + this.accessTokenLifetimeSeconds * 1_000
|
|
214
|
+
: 0;
|
|
215
|
+
this.#tokens.set(token, { subject: subjectArg, family: familyArg, expiresAt, kind: kindArg, revoked: false, used: false });
|
|
216
|
+
return { token, expiresAt };
|
|
217
|
+
}
|
|
218
|
+
isLive(tokenArg) {
|
|
219
|
+
return !tokenArg.revoked && (tokenArg.expiresAt === 0 || tokenArg.expiresAt > this.#now());
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoidGVzdGluZy5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uLy4uL3RzX3JlbHlpbmdwYXJ0eS90ZXN0aW5nLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBLE9BQU8sS0FBSyxPQUFPLE1BQU0sY0FBYyxDQUFDO0FBQ3hDLE9BQU8sRUFDTCxtQkFBbUIsRUFDbkIsdUJBQXVCLEdBU3hCLE1BQU0sOEJBQThCLENBQUM7QUFnRHRDLE1BQU0sV0FBVyxHQUFHLEdBQVcsRUFBRSxDQUFDLE9BQU8sQ0FBQyxNQUFNLENBQUMsV0FBVyxDQUFDLEVBQUUsQ0FBQyxDQUFDLFFBQVEsQ0FBQyxXQUFXLENBQUMsQ0FBQztBQUV2Rjs7Ozs7Ozs7O0dBU0c7QUFDSCxNQUFNLE9BQU8sMEJBQTBCO0lBQ3JCLHFCQUFxQixDQUFTO0lBQzlCLE1BQU0sQ0FBUztJQUN4QiwwQkFBMEIsQ0FBUztJQUUxQixjQUFjLEdBQWdDLEVBQUUsQ0FBQztJQUNqRCxXQUFXLEdBQThDLEVBQUUsQ0FBQztJQUM1RCxXQUFXLEdBQW1ELEVBQUUsQ0FBQztJQUNqRSxjQUFjLEdBQTRFLEVBQUUsQ0FBQztJQUM3RixTQUFTLEdBQWEsRUFBRSxDQUFDO0lBRXpDLG1HQUFtRztJQUM1RixpQkFBaUIsQ0FBUztJQUNqQyxtSEFBbUg7SUFDNUcsb0JBQW9CLENBQVM7SUFDcEMsNENBQTRDO0lBQ3JDLGNBQWMsQ0FBUztJQUM5QixzRUFBc0U7SUFDL0QsaUJBQWlCLENBQVM7SUFDakMsb0ZBQW9GO0lBQzdFLFlBQVksQ0FBbUc7SUFDdEgsNkVBQTZFO0lBQ3RFLFNBQVMsQ0FBOEM7SUFFckQsSUFBSSxDQUFlO0lBQ25CLFNBQVMsR0FBRyxJQUFJLEdBQUcsRUFBa0MsQ0FBQztJQUN0RCxNQUFNLEdBQUcsSUFBSSxHQUFHLEVBQXVCLENBQUM7SUFDeEMsT0FBTyxHQUFHLElBQUksR0FBRyxFQUF3QixDQUFDO0lBQ25ELEtBQUssQ0FBVTtJQUVmLFlBQVksYUFBcUMsRUFBRTtRQUNqRCxJ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
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@idp.global/sdk",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "19.1.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Reusable browser and server SDK for idp.global authentication.",
|
|
6
6
|
"type": "module",
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
".": "./dist_ts/index.js",
|
|
9
9
|
"./browser": "./dist_ts_browser/index.js",
|
|
10
10
|
"./server": "./dist_ts_server/index.js",
|
|
11
|
-
"./relyingparty": "./dist_ts_relyingparty/index.js"
|
|
11
|
+
"./relyingparty": "./dist_ts_relyingparty/index.js",
|
|
12
|
+
"./relyingparty/testing": "./dist_ts_relyingparty/testing.js"
|
|
12
13
|
},
|
|
13
14
|
"main": "dist_ts/index.js",
|
|
14
15
|
"typings": "dist_ts/index.d.ts",
|
|
@@ -18,7 +19,7 @@
|
|
|
18
19
|
"@api.global/typedrequest": "^8.2.0",
|
|
19
20
|
"@api.global/typedrequest-interfaces": "^7.1.0",
|
|
20
21
|
"@api.global/typedsocket": "^8.7.0",
|
|
21
|
-
"@idp.global/interfaces": "^
|
|
22
|
+
"@idp.global/interfaces": "^30.0.0",
|
|
22
23
|
"@push.rocks/smartjson": "^6.0.1",
|
|
23
24
|
"@push.rocks/smartjwt": "^2.7.0",
|
|
24
25
|
"@push.rocks/smartpromise": "^4.2.4",
|
|
@@ -34,13 +35,13 @@
|
|
|
34
35
|
},
|
|
35
36
|
"devDependencies": {
|
|
36
37
|
"@api.global/typedserver": "^11.4.0",
|
|
37
|
-
"@git.zone/tsbuild": "^5.
|
|
38
|
-
"@git.zone/tsdoc": "^3.0.
|
|
38
|
+
"@git.zone/tsbuild": "^5.1.2",
|
|
39
|
+
"@git.zone/tsdoc": "^3.0.2",
|
|
39
40
|
"@git.zone/tsrun": "^3.0.2",
|
|
40
|
-
"@git.zone/tstest": "^
|
|
41
|
+
"@git.zone/tstest": "^7.2.0",
|
|
41
42
|
"@lossless.org/client": "^1.0.0",
|
|
42
43
|
"@lossless.org/nosqldb": "10.5.0",
|
|
43
|
-
"@types/node": "26.6.
|
|
44
|
+
"@types/node": "26.6.3"
|
|
44
45
|
},
|
|
45
46
|
"files": [
|
|
46
47
|
"ts/**/*",
|
package/readme.md
CHANGED
|
@@ -525,6 +525,20 @@ What it does:
|
|
|
525
525
|
releases the return path only to the browser holding the session that attempt
|
|
526
526
|
created. Return paths stay on the public origin and outside the login routes.
|
|
527
527
|
Expired attempts are swept when the next login starts.
|
|
528
|
+
- **Intents and prompts**: `sessions.startLogin(returnPath, { intent, prompt })`
|
|
529
|
+
is what an application calls from a login entry of its own (an invitation it
|
|
530
|
+
accepts, an account it links, a client it authorizes). `intent` is the
|
|
531
|
+
application's text of up to 4,096 characters, sealed into the login attempt
|
|
532
|
+
like the verifier; it never travels through the browser or the provider, and
|
|
533
|
+
`principals.admit(completion, { intent, loginAttemptId })` receives it when
|
|
534
|
+
exactly this login comes back. `prompt` (`login`, `none`, `consent`) goes to
|
|
535
|
+
the authorization endpoint: `login` makes the person authenticate again
|
|
536
|
+
before something long-lived is made. A refusal from `admit` may name where
|
|
537
|
+
in the application the browser goes
|
|
538
|
+
(`new RelyingPartyRefusal(code, status, { location: '/#link/<token>' })`);
|
|
539
|
+
the routes follow it only when it is a plain path on the public origin
|
|
540
|
+
outside the login routes, exactly as given, and otherwise answer as if it
|
|
541
|
+
named none.
|
|
528
542
|
- **Sessions**: an HttpOnly, `SameSite=Lax` cookie whose token is stored as a
|
|
529
543
|
hash, touched at most once a minute, with a CSRF token derived per request as
|
|
530
544
|
an HMAC of the cookie token. `GET /auth/session` answers the session and
|
|
@@ -532,7 +546,9 @@ What it does:
|
|
|
532
546
|
cancelled as soon as it passes `maxBodyBytes`. Every end of a session is one
|
|
533
547
|
conditional update; its provider tokens go back to idp.global within a
|
|
534
548
|
three-second budget afterwards, and so do the tokens of a login that failed
|
|
535
|
-
after the code exchange.
|
|
549
|
+
after the code exchange. `openSession({ principalId, maxAgeSeconds })` opens
|
|
550
|
+
a session the application authenticated itself, optionally shorter than
|
|
551
|
+
`sessionMaxAgeSeconds` (the cookie with it), and without provider tokens.
|
|
536
552
|
- **Provider tokens**: sealed with AES-256-GCM (`SecretBox`) and rotated shortly
|
|
537
553
|
before they expire, once per process and under a lock stored on the session,
|
|
538
554
|
so two replicas never spend one refresh token. A refused refresh ends the
|
|
@@ -660,7 +676,59 @@ then the application's. The two options are not combined.
|
|
|
660
676
|
|
|
661
677
|
**Socket peers.** `RelyingPartySocketBinding.tagConnection` and `upgradeOf` take
|
|
662
678
|
the connection wrapper or the bare SmartServe peer (`TRelyingPartySocketPeer`),
|
|
663
|
-
the two forms `TypedSocket.setServerTag` accepts.
|
|
679
|
+
the two forms `TypedSocket.setServerTag` accepts. The binding names no
|
|
680
|
+
TypedSocket or SmartServe version: its transport, connection and peer types are
|
|
681
|
+
only what it reads (`setServerTag`, `findAllTargetConnectionsByTag`, a peer's
|
|
682
|
+
upgrade headers), so the application's own server `TypedSocket` fits whichever
|
|
683
|
+
major it runs. Name the application's connection type as the second type
|
|
684
|
+
parameter and `connectionsOfSession` answers it:
|
|
685
|
+
|
|
686
|
+
```ts
|
|
687
|
+
import type { ISmartServeConnectionWrapper } from '@api.global/typedsocket';
|
|
688
|
+
|
|
689
|
+
const binding = new RelyingPartySocketBinding<IUser, ISmartServeConnectionWrapper>({
|
|
690
|
+
sessions,
|
|
691
|
+
transport: () => typedserver.typedsocket,
|
|
692
|
+
});
|
|
693
|
+
for (const connection of await binding.connectionsOfSession(sessionId)) {
|
|
694
|
+
typedserver.removeServerTag(connection, binding.tagOf(sessionId));
|
|
695
|
+
}
|
|
696
|
+
```
|
|
697
|
+
|
|
698
|
+
### Testing an Application
|
|
699
|
+
|
|
700
|
+
`@idp.global/sdk/relyingparty/testing` exports `RelyingPartyProviderDouble`, an
|
|
701
|
+
in-process idp.global implementing `IOidcProvider` for an application's own
|
|
702
|
+
tests; it belongs in test code only.
|
|
703
|
+
|
|
704
|
+
```ts
|
|
705
|
+
import { RelyingPartyProviderDouble } from '@idp.global/sdk/relyingparty/testing';
|
|
706
|
+
|
|
707
|
+
const idp = new RelyingPartyProviderDouble({ now: clock.now });
|
|
708
|
+
idp.addPersona({
|
|
709
|
+
subject: 'sub-owner',
|
|
710
|
+
displayName: 'Olga Owner',
|
|
711
|
+
email: 'olga@example.com',
|
|
712
|
+
organizations: [{ id: 'idp-org-1', name: 'Example GmbH', slug: 'example', roles: ['owner'] }],
|
|
713
|
+
});
|
|
714
|
+
idp.signInNextAs('sub-owner');
|
|
715
|
+
|
|
716
|
+
// the application's GET /auth/login answers with the authorization URL …
|
|
717
|
+
const callback = idp.callbackUrlFor(authorizationUrl, new URL('/auth/callback', publicOrigin));
|
|
718
|
+
// … and the browser comes back to the application's callback with it
|
|
719
|
+
|
|
720
|
+
idp.setOrganizationRoles('sub-owner', 'idp-org-1', ['viewer']); // what introspection answers from now on
|
|
721
|
+
idp.endLoginsOf('sub-owner'); // every token of the persona stops working
|
|
722
|
+
```
|
|
723
|
+
|
|
724
|
+
Codes are single use and bound to their login's state and nonce; access tokens
|
|
725
|
+
introspect for their subject while they live; refresh tokens rotate, and a
|
|
726
|
+
superseded one coming back revokes its whole family, as idp.global's reuse
|
|
727
|
+
detection does. Every call is recorded (`authorizations`, `completions`,
|
|
728
|
+
`revocations`, `introspections`, `refreshes`), and each step can be made to
|
|
729
|
+
fail (`completionFailure`, `introspectionFailure`, `refreshFailure`,
|
|
730
|
+
`revocationFailure`) or held open (`onIntrospect`, `onRefresh`). The claims of a
|
|
731
|
+
login are those of its sign-in; introspection reads the persona as it is now.
|
|
664
732
|
|
|
665
733
|
### Relying-Party API Surface
|
|
666
734
|
|
|
@@ -668,13 +736,14 @@ the two forms `TypedSocket.setServerTag` accepts.
|
|
|
668
736
|
| --- | --- |
|
|
669
737
|
| `IdpOidcProvider` | Discovery, PKCE authorization URL, code exchange with UserInfo, refresh, revocation and organization introspection for idp.global. Implements `IOidcProvider`. `trustedCaPem` or `fetch` for an issuer under a private CA. |
|
|
670
738
|
| `createRelyingPartyModels()` | Binds the session and login-attempt models to one `SmartdataDb`, or to a function asked for it on every use, optionally under existing collection names. |
|
|
671
|
-
| `RelyingPartySessions<TPrincipal>` | Login start/complete/abandon/finalize, `openSession
|
|
739
|
+
| `RelyingPartySessions<TPrincipal>` | Login start (with an application intent and a prompt)/complete/abandon/finalize, `openSession` (with a shorter lifetime), `resolveCookieSession`, `revalidateSession`, `revalidateLiveSession`, `isSessionLive`, `validateCsrf`, `logout`/`endSession`, `endSessionsOfPrincipal`, `liveProviderToken`. Principals give `isActive` or `resolve` (`IRelyingPartyPrincipals`, `IRelyingPartyPrincipalResolver<TPrincipal>`); with `resolve`, live sessions carry `principal`. |
|
|
672
740
|
| `AuthorityObserver` | Live organization authority with opt-in positive and negative reuse. |
|
|
673
741
|
| `RelyingPartyRoutes` | `handle(ctx)` for `/auth/login`, `/auth/callback`, `/auth/finalize`, `/auth/session` and `/auth/logout`; `null` for anything else. Takes a typedserver route context. `onLogout` is awaited after a CSRF-checked logout ended the session. |
|
|
674
742
|
| `createActorScope()` | AsyncLocalStorage actor context with a TypedRouter request scope. |
|
|
675
743
|
| `RelyingPartySocketBinding` | TypedSocket browser registration, per-request re-validation and session tags; takes a connection wrapper or its peer. |
|
|
676
744
|
| `SecretBox`, `deriveCsrfToken()`, `decodeSecretKey()`, `parseCookies()`, `serializeCookie()`, `sanitizeReturnPath()` | The primitives underneath. |
|
|
677
|
-
| `RelyingPartyError`, `RelyingPartyRefusal`, `LoginCallbackError`, `AuthorityTokenError`, `AuthorityRefusedError`, `RelyingPartyDatabaseUnavailableError` | Errors with stable codes; none carries token material. |
|
|
745
|
+
| `RelyingPartyError`, `RelyingPartyRefusal`, `LoginCallbackError`, `AuthorityTokenError`, `AuthorityRefusedError`, `RelyingPartyDatabaseUnavailableError` | Errors with stable codes; none carries token material. A `RelyingPartyRefusal` may name the application's `location` for the browser. |
|
|
746
|
+
| `RelyingPartyProviderDouble` (`/relyingparty/testing`) | An in-process idp.global for an application's tests: personas, live organization roles, single-use codes, rotating and reuse-detecting refresh tokens, recorded calls and injectable failures. |
|
|
678
747
|
|
|
679
748
|
## Runtime Notes
|
|
680
749
|
|
|
@@ -693,7 +762,7 @@ The SDK uses typed sockets and typed requests from `@api.global/*`, shared idp.g
|
|
|
693
762
|
pnpm test
|
|
694
763
|
```
|
|
695
764
|
|
|
696
|
-
The test suite covers the server account store and auth service, browser transfer-token handling, typed administrative request names, the exact backend JWT keyset and blocklist contracts, and the relying-party layer: its primitives, the OIDC provider against a fetch-level issuer double, sessions and two-replica token rotation on an in-memory NoSQL engine with an injected clock, the persisted shape, the authority observer, the HTTP routes, the actor scope and the
|
|
765
|
+
The test suite covers the server account store and auth service, browser transfer-token handling, typed administrative request names, the exact backend JWT keyset and blocklist contracts, and the relying-party layer: its primitives, the OIDC provider against a fetch-level issuer double, sessions and two-replica token rotation on an in-memory NoSQL engine with an injected clock, the persisted shape, the authority observer, the HTTP routes, login intents and prompts, the actor scope, the socket binding and the provider double.
|
|
697
766
|
|
|
698
767
|
## License and Legal Information
|
|
699
768
|
|
package/ts/00_commitinfo_data.ts
CHANGED
|
@@ -30,14 +30,25 @@ export class RelyingPartyError extends Error {
|
|
|
30
30
|
* The application refuses a verified provider login (a disabled account, a
|
|
31
31
|
* subject of another issuer, no organization it serves). Thrown from
|
|
32
32
|
* `IRelyingPartyPrincipals.admit`; it reaches the caller unchanged.
|
|
33
|
+
* `location` is where in the application the browser goes for this very
|
|
34
|
+
* refusal (a step it has to take, with a one-time token in the fragment);
|
|
35
|
+
* `RelyingPartyRoutes` follows it only when it is a plain path on the
|
|
36
|
+
* application's origin outside the login routes, and otherwise answers as if
|
|
37
|
+
* none was given.
|
|
33
38
|
*/
|
|
34
39
|
export class RelyingPartyRefusal extends Error {
|
|
40
|
+
public readonly location?: string;
|
|
41
|
+
|
|
35
42
|
constructor(
|
|
36
43
|
public readonly code: string,
|
|
37
44
|
public readonly status: number,
|
|
45
|
+
optionsArg: { location?: string } = {},
|
|
38
46
|
) {
|
|
39
47
|
super(code);
|
|
40
48
|
this.name = 'RelyingPartyRefusal';
|
|
49
|
+
if (optionsArg.location !== undefined) {
|
|
50
|
+
this.location = optionsArg.location;
|
|
51
|
+
}
|
|
41
52
|
}
|
|
42
53
|
}
|
|
43
54
|
|
|
@@ -5,10 +5,22 @@ import {
|
|
|
5
5
|
} from '../ts_server/classes.oidc-introspection-client.js';
|
|
6
6
|
import { createTrustedCaFetch, type TOidcFetch } from '../ts_server/helpers.trusted-ca-fetch.js';
|
|
7
7
|
|
|
8
|
+
/** The `prompt` values idp.global takes at its authorization endpoint. */
|
|
9
|
+
export type TOidcPrompt = 'none' | 'login' | 'consent';
|
|
10
|
+
|
|
11
|
+
export const OIDC_PROMPTS: readonly TOidcPrompt[] = ['none', 'login', 'consent'];
|
|
12
|
+
|
|
8
13
|
export interface IOidcAuthorizationOptions {
|
|
9
14
|
state: string;
|
|
10
15
|
nonce: string;
|
|
11
16
|
pkceVerifier: string;
|
|
17
|
+
/**
|
|
18
|
+
* What the provider is asked to do with a login it holds already: `login`
|
|
19
|
+
* asks the person to authenticate again (a fresh proof before something
|
|
20
|
+
* long-lived is made), `none` never shows a page, `consent` asks again.
|
|
21
|
+
* Absent: the provider decides.
|
|
22
|
+
*/
|
|
23
|
+
prompt?: TOidcPrompt;
|
|
12
24
|
}
|
|
13
25
|
|
|
14
26
|
export interface IOidcCompletionOptions extends IOidcAuthorizationOptions {
|
|
@@ -300,6 +312,7 @@ export class IdpOidcProvider implements IOidcProvider {
|
|
|
300
312
|
code_challenge_method: 'S256',
|
|
301
313
|
state: optionsArg.state,
|
|
302
314
|
nonce: optionsArg.nonce,
|
|
315
|
+
...(optionsArg.prompt ? { prompt: optionsArg.prompt } : {}),
|
|
303
316
|
});
|
|
304
317
|
}
|
|
305
318
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as plugins from './plugins.js';
|
|
2
2
|
import { LoginCallbackError, RelyingPartyError, RelyingPartyRefusal } from './classes.errors.js';
|
|
3
3
|
import type { RelyingPartySessions } from './classes.relyingpartysessions.js';
|
|
4
|
+
import { sanitizeReturnPath } from './helpers.returnpath.js';
|
|
4
5
|
import type { IRelyingPartySession, IResolvedRelyingPartySession } from './interfaces.js';
|
|
5
6
|
|
|
6
7
|
/**
|
|
@@ -167,6 +168,20 @@ export class RelyingPartyRoutes<TPrincipal extends object | undefined = undefine
|
|
|
167
168
|
}
|
|
168
169
|
}
|
|
169
170
|
|
|
171
|
+
/**
|
|
172
|
+
* The location a refusal names itself, when it is a plain path on this
|
|
173
|
+
* origin outside the login routes exactly as given; anything the sanitizer
|
|
174
|
+
* would have to change is no location of this application.
|
|
175
|
+
*/
|
|
176
|
+
private refusalLocationOf(refusalArg: RelyingPartyRefusal): string | undefined {
|
|
177
|
+
const location = refusalArg.location;
|
|
178
|
+
if (typeof location !== 'string' || location.length === 0) {
|
|
179
|
+
return undefined;
|
|
180
|
+
}
|
|
181
|
+
const sanitized = sanitizeReturnPath(location, this.#sessions.publicOrigin, this.#sessions.loginPathPrefix);
|
|
182
|
+
return sanitized === location ? sanitized : undefined;
|
|
183
|
+
}
|
|
184
|
+
|
|
170
185
|
private async callback(contextArg: IRelyingPartyHttpContext): Promise<Response> {
|
|
171
186
|
// the callback is read from the public origin, never from a forwarded host
|
|
172
187
|
const callbackUrl = new URL(contextArg.url.pathname + contextArg.url.search, this.#sessions.publicOrigin);
|
|
@@ -186,6 +201,10 @@ export class RelyingPartyRoutes<TPrincipal extends object | undefined = undefine
|
|
|
186
201
|
const clearIntent = { 'Set-Cookie': this.#sessions.clearIntentCookie() };
|
|
187
202
|
if (error instanceof RelyingPartyRefusal) {
|
|
188
203
|
this.emit({ event: 'auth_callback_refused', code: error.code });
|
|
204
|
+
const own = this.refusalLocationOf(error);
|
|
205
|
+
if (own) {
|
|
206
|
+
return redirect(own, clearIntent);
|
|
207
|
+
}
|
|
189
208
|
const locations = this.#options.refusalLocations ?? {};
|
|
190
209
|
if (Object.hasOwn(locations, error.code)) {
|
|
191
210
|
return redirect(locations[error.code], clearIntent);
|