@udibo/oauth2 0.26.0 → 0.28.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 +5 -3
- package/dist/adapters/hono/authorization-server.d.ts +21 -8
- package/dist/adapters/hono/authorization-server.js +2 -2
- package/dist/adapters/hono/bff/bff.d.ts +18 -4
- package/dist/adapters/hono/bff/bff.js +69 -3
- package/dist/cli/idp/server.js +35 -14
- package/dist/client/_http.d.ts +6 -1
- package/dist/client/_http.js +15 -2
- package/dist/client/_issuer-refusal.d.ts +7 -0
- package/dist/client/_issuer-refusal.js +22 -0
- package/dist/client/direct-client.d.ts +2 -1
- package/dist/client/direct-client.js +9 -22
- package/dist/errors.d.ts +34 -0
- package/dist/errors.js +58 -0
- package/dist/identity/external/_shared.d.ts +18 -0
- package/dist/identity/external/_shared.js +15 -0
- package/dist/identity/external/apple.d.ts +3 -0
- package/dist/identity/external/apple.js +10 -1
- package/dist/identity/external/errors.d.ts +3 -1
- package/dist/identity/external/flow.d.ts +4 -2
- package/dist/identity/external/flow.js +10 -2
- package/dist/identity/external/oauth2.d.ts +19 -1
- package/dist/identity/external/oauth2.js +21 -2
- package/dist/identity/external/oidc.d.ts +6 -0
- package/dist/identity/external/oidc.js +13 -1
- package/dist/identity/external/provider.d.ts +12 -2
- package/dist/models/authentication.d.ts +28 -0
- package/dist/models/authorization-code.d.ts +3 -3
- package/dist/models/responses.d.ts +8 -2
- package/dist/server/authentication-request.d.ts +60 -0
- package/dist/server/authentication-request.js +102 -0
- package/dist/server/authorization-server.d.ts +55 -7
- package/dist/server/authorization-server.js +54 -11
- package/dist/server/mod.d.ts +3 -2
- package/dist/server/mod.js +2 -1
- package/package.json +1 -1
|
@@ -53,6 +53,24 @@ export declare function readProviderText(res: Response, provider: string, source
|
|
|
53
53
|
* the body, so the caller must not read it again.
|
|
54
54
|
*/
|
|
55
55
|
export declare function readErrorBody(res: Response): Promise<string>;
|
|
56
|
+
/** Input to {@link assertAuthorizationResponseIssuer}. */
|
|
57
|
+
export interface AuthorizationResponseIssuerInput {
|
|
58
|
+
/** The connector id, for the error prefix. */
|
|
59
|
+
provider: string;
|
|
60
|
+
/** The callback's `iss`, from {@link ExternalProfileInput.iss}. */
|
|
61
|
+
iss: string | undefined;
|
|
62
|
+
/** The provider's issuer identifier; `iss` must equal it exactly. */
|
|
63
|
+
issuer: string;
|
|
64
|
+
/** Whether the provider advertises `authorization_response_iss_parameter_supported`. */
|
|
65
|
+
required: boolean;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Refuses a callback whose `iss` (RFC 9207 §2.4) is not exactly `issuer`, or
|
|
69
|
+
* is absent while `required`. Call it before redeeming the code.
|
|
70
|
+
*
|
|
71
|
+
* @throws {ExternalAuthError} `invalid_callback` when the response is refused.
|
|
72
|
+
*/
|
|
73
|
+
export declare function assertAuthorizationResponseIssuer(input: AuthorizationResponseIssuerInput): void;
|
|
56
74
|
/**
|
|
57
75
|
* Drops one trailing `"/"` so issuer URLs compare and concatenate consistently
|
|
58
76
|
* (`"https://x/"` and `"https://x"` identify the same issuer).
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*
|
|
9
9
|
* @module
|
|
10
10
|
*/
|
|
11
|
+
import { authorizationResponseIssuerProblem } from "../../client/_issuer-refusal.js";
|
|
11
12
|
import { isOAuth2Error } from "../../errors.js";
|
|
12
13
|
import { readBoundedBody } from "../../utils/_body.js";
|
|
13
14
|
import { timingSafeEqualString } from "../../utils/crypto.js";
|
|
@@ -90,6 +91,20 @@ export async function readErrorBody(res) {
|
|
|
90
91
|
return "";
|
|
91
92
|
}
|
|
92
93
|
}
|
|
94
|
+
/**
|
|
95
|
+
* Refuses a callback whose `iss` (RFC 9207 §2.4) is not exactly `issuer`, or
|
|
96
|
+
* is absent while `required`. Call it before redeeming the code.
|
|
97
|
+
*
|
|
98
|
+
* @throws {ExternalAuthError} `invalid_callback` when the response is refused.
|
|
99
|
+
*/
|
|
100
|
+
export function assertAuthorizationResponseIssuer(input) {
|
|
101
|
+
const problem = authorizationResponseIssuerProblem(input.iss === undefined ? [] : [input.iss], [input.issuer], input.required);
|
|
102
|
+
if (problem === undefined)
|
|
103
|
+
return;
|
|
104
|
+
throw new ExternalAuthError(input.provider, "invalid_callback", `${problem}. The callback did not come from "${input.issuer}" — a ` +
|
|
105
|
+
`response from another provider replayed here, or a provider that ` +
|
|
106
|
+
`does not send the issuer it advertises. Ask the user to start again.`);
|
|
107
|
+
}
|
|
93
108
|
/**
|
|
94
109
|
* Drops one trailing `"/"` so issuer URLs compare and concatenate consistently
|
|
95
110
|
* (`"https://x/"` and `"https://x"` identify the same issuer).
|
|
@@ -50,6 +50,9 @@ export interface AppleProviderOptions extends Pick<AppleClientSecretOptions, "te
|
|
|
50
50
|
* `id_token`'s RS256 signature against Apple's published keys before trusting
|
|
51
51
|
* its claims (`iss`, `aud`, `azp`, `exp`, and the `nonce` when one was sent,
|
|
52
52
|
* checked as {@link oidcProvider} checks them, with the default `azp` policy).
|
|
53
|
+
* A callback `iss` (RFC 9207) other than `https://appleid.apple.com` is
|
|
54
|
+
* refused before the code is redeemed; Apple does not advertise the
|
|
55
|
+
* parameter, so a callback without one is accepted.
|
|
53
56
|
* `emailVerified` is `true` when Apple's `email_verified` claim is `true` or
|
|
54
57
|
* the string `"true"`; private-relay addresses
|
|
55
58
|
* (`@privaterelay.appleid.com`) are returned as-is. The raw `id_token`
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
import { toArrayBuffer } from "../../utils/_buffer.js";
|
|
19
19
|
import { base64urlDecode } from "../../utils/crypto.js";
|
|
20
20
|
import { defaultFetch, packageDeadlineSignal, } from "../../utils/_default-fetch.js";
|
|
21
|
-
import { assertIdTokenClaims, describeError, PROVIDER_REQUEST_TIMEOUT_MS, readProviderText, } from "./_shared.js";
|
|
21
|
+
import { assertAuthorizationResponseIssuer, assertIdTokenClaims, describeError, PROVIDER_REQUEST_TIMEOUT_MS, readProviderText, } from "./_shared.js";
|
|
22
22
|
import { runTokenExchange } from "./_token-exchange.js";
|
|
23
23
|
import { createAppleClientSecretFactory, } from "./apple-client-secret.js";
|
|
24
24
|
import { ExternalAuthError } from "./errors.js";
|
|
@@ -41,6 +41,9 @@ const SIGNATURE_NOT_VERIFIED = `Apple id_token signature did not verify against
|
|
|
41
41
|
* `id_token`'s RS256 signature against Apple's published keys before trusting
|
|
42
42
|
* its claims (`iss`, `aud`, `azp`, `exp`, and the `nonce` when one was sent,
|
|
43
43
|
* checked as {@link oidcProvider} checks them, with the default `azp` policy).
|
|
44
|
+
* A callback `iss` (RFC 9207) other than `https://appleid.apple.com` is
|
|
45
|
+
* refused before the code is redeemed; Apple does not advertise the
|
|
46
|
+
* parameter, so a callback without one is accepted.
|
|
44
47
|
* `emailVerified` is `true` when Apple's `email_verified` claim is `true` or
|
|
45
48
|
* the string `"true"`; private-relay addresses
|
|
46
49
|
* (`@privaterelay.appleid.com`) are returned as-is. The raw `id_token`
|
|
@@ -205,6 +208,12 @@ export function appleProvider(options) {
|
|
|
205
208
|
return Promise.resolve(url.toString());
|
|
206
209
|
},
|
|
207
210
|
async fetchProfile(input) {
|
|
211
|
+
assertAuthorizationResponseIssuer({
|
|
212
|
+
provider: id,
|
|
213
|
+
iss: input.iss,
|
|
214
|
+
issuer: APPLE_ISSUER,
|
|
215
|
+
required: false,
|
|
216
|
+
});
|
|
208
217
|
const idToken = await exchangeCode(input);
|
|
209
218
|
const claims = await verifyIdToken(idToken, input.nonce);
|
|
210
219
|
return profileFromClaims(claims);
|
|
@@ -19,7 +19,9 @@
|
|
|
19
19
|
* - `provider_error` — the provider rejected the request (callback `error`
|
|
20
20
|
* param, token-endpoint error, profile-endpoint failure, malformed
|
|
21
21
|
* response). The message carries the provider's error code/description.
|
|
22
|
-
* - `invalid_callback` — the callback URL is missing `code` or `state
|
|
22
|
+
* - `invalid_callback` — the callback URL is missing `code` or `state`, or
|
|
23
|
+
* its `iss` (RFC 9207) is not the provider's issuer: repeated, naming
|
|
24
|
+
* another issuer, or missing from a provider that advertises it.
|
|
23
25
|
* - `state_mismatch` — the callback `state` does not match the transient
|
|
24
26
|
* (possible CSRF or a stale/mixed-up sign-in attempt).
|
|
25
27
|
* - `transient_expired` — the sign-in attempt outlived the configured max
|
|
@@ -99,8 +99,10 @@ export declare class ExternalAuthFlow {
|
|
|
99
99
|
* Completes a sign-in attempt from the provider callback: checks the
|
|
100
100
|
* transient belongs to this flow's provider, enforces the max transient
|
|
101
101
|
* age, verifies `state` with a timing-safe compare, and only then surfaces a
|
|
102
|
-
* provider `error` callback param
|
|
103
|
-
*
|
|
102
|
+
* provider `error` callback param. A callback with more than one `iss` is
|
|
103
|
+
* refused; a single `iss` is handed to the provider, which checks it against
|
|
104
|
+
* its issuer (RFC 9207) before it exchanges the code. The provider then
|
|
105
|
+
* (for nonce-using providers) validates the id_token nonce, and the
|
|
104
106
|
* normalized profile is returned.
|
|
105
107
|
*
|
|
106
108
|
* @throws {ExternalAuthError} with a code describing the failure — see
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*
|
|
7
7
|
* @module
|
|
8
8
|
*/
|
|
9
|
+
import { DUPLICATE_ISS_PROBLEM } from "../../client/_issuer-refusal.js";
|
|
9
10
|
import { timingSafeEqualString } from "../../utils/crypto.js";
|
|
10
11
|
import { generateCodeVerifier, generateState } from "../../utils/pkce.js";
|
|
11
12
|
import { ExternalAuthError } from "./errors.js";
|
|
@@ -91,8 +92,10 @@ export class ExternalAuthFlow {
|
|
|
91
92
|
* Completes a sign-in attempt from the provider callback: checks the
|
|
92
93
|
* transient belongs to this flow's provider, enforces the max transient
|
|
93
94
|
* age, verifies `state` with a timing-safe compare, and only then surfaces a
|
|
94
|
-
* provider `error` callback param
|
|
95
|
-
*
|
|
95
|
+
* provider `error` callback param. A callback with more than one `iss` is
|
|
96
|
+
* refused; a single `iss` is handed to the provider, which checks it against
|
|
97
|
+
* its issuer (RFC 9207) before it exchanges the code. The provider then
|
|
98
|
+
* (for nonce-using providers) validates the id_token nonce, and the
|
|
96
99
|
* normalized profile is returned.
|
|
97
100
|
*
|
|
98
101
|
* @throws {ExternalAuthError} with a code describing the failure — see
|
|
@@ -134,11 +137,16 @@ export class ExternalAuthFlow {
|
|
|
134
137
|
if (!code) {
|
|
135
138
|
throw new ExternalAuthError(providerId, "invalid_callback", `callback is missing the "code" parameter.`);
|
|
136
139
|
}
|
|
140
|
+
const issValues = params.getAll("iss");
|
|
141
|
+
if (issValues.length > 1) {
|
|
142
|
+
throw new ExternalAuthError(providerId, "invalid_callback", `${DUPLICATE_ISS_PROBLEM}. Ask the user to start again.`);
|
|
143
|
+
}
|
|
137
144
|
return await this.provider.fetchProfile({
|
|
138
145
|
code,
|
|
139
146
|
redirectUri: transient.redirectUri,
|
|
140
147
|
codeVerifier: transient.codeVerifier,
|
|
141
148
|
nonce: transient.nonce,
|
|
149
|
+
...(issValues.length === 1 ? { iss: issValues[0] } : {}),
|
|
142
150
|
});
|
|
143
151
|
}
|
|
144
152
|
}
|
|
@@ -85,6 +85,20 @@ export interface OAuth2ProviderOptions {
|
|
|
85
85
|
* downgrade PKCE.
|
|
86
86
|
*/
|
|
87
87
|
authorizationParams?: Record<string, string>;
|
|
88
|
+
/**
|
|
89
|
+
* The provider's issuer identifier, for the RFC 9207 check. When set, a
|
|
90
|
+
* callback `iss` that is not exactly this value is refused before the code
|
|
91
|
+
* is redeemed. Unset, the callback's `iss` is not checked.
|
|
92
|
+
*/
|
|
93
|
+
issuer?: string;
|
|
94
|
+
/**
|
|
95
|
+
* Set `true` when the provider always sends `iss` in its authorization
|
|
96
|
+
* responses (its metadata advertises
|
|
97
|
+
* `authorization_response_iss_parameter_supported`), so a callback without
|
|
98
|
+
* one is refused. Requires {@link OAuth2ProviderOptions.issuer}. Defaults to
|
|
99
|
+
* `false`.
|
|
100
|
+
*/
|
|
101
|
+
authorizationResponseIssParameterSupported?: boolean;
|
|
88
102
|
/**
|
|
89
103
|
* Fetch implementation for all provider traffic. Defaults to
|
|
90
104
|
* `globalThis.fetch`. A wrapper must pass `init` through intact: its
|
|
@@ -100,7 +114,11 @@ export interface OAuth2ProviderOptions {
|
|
|
100
114
|
* credentials in the body (`client_secret_post`); the profile is read from
|
|
101
115
|
* `userInfoEndpoint` with the bearer token when set, else from the token
|
|
102
116
|
* response, and handed to `mapProfile`. Presets like {@link discordProvider}
|
|
103
|
-
* are thin wrappers over this.
|
|
117
|
+
* are thin wrappers over this. With `issuer` set, the callback's `iss`
|
|
118
|
+
* (RFC 9207) is checked before the exchange.
|
|
119
|
+
*
|
|
120
|
+
* @throws {ExternalAuthError} `configuration` when
|
|
121
|
+
* `authorizationResponseIssParameterSupported` is set without `issuer`.
|
|
104
122
|
*
|
|
105
123
|
* @example
|
|
106
124
|
* ```ts
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { defaultFetch, packageDeadlineSignal, } from "../../utils/_default-fetch.js";
|
|
11
11
|
import { generateCodeChallenge } from "../../utils/pkce.js";
|
|
12
|
-
import { describeError, PROVIDER_REQUEST_TIMEOUT_MS, readErrorBody, readProviderText, } from "./_shared.js";
|
|
12
|
+
import { assertAuthorizationResponseIssuer, describeError, PROVIDER_REQUEST_TIMEOUT_MS, readErrorBody, readProviderText, } from "./_shared.js";
|
|
13
13
|
import { runTokenExchange } from "./_token-exchange.js";
|
|
14
14
|
import { ExternalAuthError } from "./errors.js";
|
|
15
15
|
/**
|
|
@@ -20,7 +20,11 @@ import { ExternalAuthError } from "./errors.js";
|
|
|
20
20
|
* credentials in the body (`client_secret_post`); the profile is read from
|
|
21
21
|
* `userInfoEndpoint` with the bearer token when set, else from the token
|
|
22
22
|
* response, and handed to `mapProfile`. Presets like {@link discordProvider}
|
|
23
|
-
* are thin wrappers over this.
|
|
23
|
+
* are thin wrappers over this. With `issuer` set, the callback's `iss`
|
|
24
|
+
* (RFC 9207) is checked before the exchange.
|
|
25
|
+
*
|
|
26
|
+
* @throws {ExternalAuthError} `configuration` when
|
|
27
|
+
* `authorizationResponseIssParameterSupported` is set without `issuer`.
|
|
24
28
|
*
|
|
25
29
|
* @example
|
|
26
30
|
* ```ts
|
|
@@ -40,6 +44,13 @@ import { ExternalAuthError } from "./errors.js";
|
|
|
40
44
|
*/
|
|
41
45
|
export function oauth2Provider(options) {
|
|
42
46
|
const id = options.id;
|
|
47
|
+
const { issuer } = options;
|
|
48
|
+
const issRequired = options.authorizationResponseIssParameterSupported === true;
|
|
49
|
+
if (issRequired && issuer === undefined) {
|
|
50
|
+
throw new ExternalAuthError(id, "configuration", `authorizationResponseIssParameterSupported needs an issuer to ` +
|
|
51
|
+
`compare the callback's "iss" with — set the provider's issuer ` +
|
|
52
|
+
`identifier as "issuer".`);
|
|
53
|
+
}
|
|
43
54
|
const fetchImpl = (input, init) => (options.fetch ?? defaultFetch)(input, init);
|
|
44
55
|
const scopeSeparator = options.scopeSeparator ?? " ";
|
|
45
56
|
async function exchangeCode(input) {
|
|
@@ -120,6 +131,14 @@ export function oauth2Provider(options) {
|
|
|
120
131
|
return url.toString();
|
|
121
132
|
},
|
|
122
133
|
async fetchProfile(input) {
|
|
134
|
+
if (issuer !== undefined) {
|
|
135
|
+
assertAuthorizationResponseIssuer({
|
|
136
|
+
provider: id,
|
|
137
|
+
iss: input.iss,
|
|
138
|
+
issuer,
|
|
139
|
+
required: issRequired,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
123
142
|
const tokens = await exchangeCode(input);
|
|
124
143
|
const profile = options.userInfoEndpoint
|
|
125
144
|
? await fetchUserInfo(tokens.accessToken)
|
|
@@ -65,6 +65,12 @@ export interface OidcProviderOptions {
|
|
|
65
65
|
* id_token `sub` (OIDC Core §5.3.2) is discarded, and a UserInfo failure is
|
|
66
66
|
* non-fatal — the profile then comes from the id_token claims alone.
|
|
67
67
|
*
|
|
68
|
+
* **Authorization response issuer:** before the code is redeemed, a callback
|
|
69
|
+
* `iss` (RFC 9207) that is not exactly the discovered issuer is refused, and
|
|
70
|
+
* so is a missing `iss` when the discovery document advertises
|
|
71
|
+
* `authorization_response_iss_parameter_supported: true`. A provider that
|
|
72
|
+
* does not advertise it may leave `iss` out.
|
|
73
|
+
*
|
|
68
74
|
* **id_token validation:** the `iss`, `aud`, `azp` (when the token has multiple
|
|
69
75
|
* audiences or an `azp` claim, unless `azp: "ignore"` is configured), `exp`,
|
|
70
76
|
* and `nonce` claims are checked per OpenID Connect Core §3.1.3.7. The
|
|
@@ -9,7 +9,7 @@ import { MemoryAuthRequestStorage } from "../../client/storage.js";
|
|
|
9
9
|
import { isOAuth2Error } from "../../errors.js";
|
|
10
10
|
import { defaultFetch, packageDeadlineSignal, } from "../../utils/_default-fetch.js";
|
|
11
11
|
import { generateCodeChallenge } from "../../utils/pkce.js";
|
|
12
|
-
import { assertIdTokenClaims, describeError, PROVIDER_REQUEST_TIMEOUT_MS, readProviderText, stripTrailingSlash, } from "./_shared.js";
|
|
12
|
+
import { assertAuthorizationResponseIssuer, assertIdTokenClaims, describeError, PROVIDER_REQUEST_TIMEOUT_MS, readProviderText, stripTrailingSlash, } from "./_shared.js";
|
|
13
13
|
import { ExternalAuthError } from "./errors.js";
|
|
14
14
|
/**
|
|
15
15
|
* Creates a connector for any spec-compliant OpenID Connect provider.
|
|
@@ -23,6 +23,12 @@ import { ExternalAuthError } from "./errors.js";
|
|
|
23
23
|
* id_token `sub` (OIDC Core §5.3.2) is discarded, and a UserInfo failure is
|
|
24
24
|
* non-fatal — the profile then comes from the id_token claims alone.
|
|
25
25
|
*
|
|
26
|
+
* **Authorization response issuer:** before the code is redeemed, a callback
|
|
27
|
+
* `iss` (RFC 9207) that is not exactly the discovered issuer is refused, and
|
|
28
|
+
* so is a missing `iss` when the discovery document advertises
|
|
29
|
+
* `authorization_response_iss_parameter_supported: true`. A provider that
|
|
30
|
+
* does not advertise it may leave `iss` out.
|
|
31
|
+
*
|
|
26
32
|
* **id_token validation:** the `iss`, `aud`, `azp` (when the token has multiple
|
|
27
33
|
* audiences or an `azp` claim, unless `azp: "ignore"` is configured), `exp`,
|
|
28
34
|
* and `nonce` claims are checked per OpenID Connect Core §3.1.3.7. The
|
|
@@ -191,6 +197,12 @@ export function oidcProvider(options) {
|
|
|
191
197
|
},
|
|
192
198
|
async fetchProfile(input) {
|
|
193
199
|
const meta = await discover();
|
|
200
|
+
assertAuthorizationResponseIssuer({
|
|
201
|
+
provider: id,
|
|
202
|
+
iss: input.iss,
|
|
203
|
+
issuer: meta.issuer,
|
|
204
|
+
required: meta.authorization_response_iss_parameter_supported === true,
|
|
205
|
+
});
|
|
194
206
|
const response = await exchangeCode(input);
|
|
195
207
|
const claims = decodeAndValidateIdToken(response, meta, input.nonce);
|
|
196
208
|
const userInfo = await fetchUserInfoClaims(meta, response.access_token, claims.sub);
|
|
@@ -93,6 +93,14 @@ export interface ExternalProfileInput {
|
|
|
93
93
|
codeVerifier?: string;
|
|
94
94
|
/** The expected id_token `nonce` from the transient, when one was sent. */
|
|
95
95
|
nonce?: string;
|
|
96
|
+
/**
|
|
97
|
+
* The callback's `iss` parameter (RFC 9207), when it carried one. Refuse
|
|
98
|
+
* the callback before redeeming `code` when this is not exactly the
|
|
99
|
+
* provider's issuer, or when it is missing and the provider advertises
|
|
100
|
+
* `authorization_response_iss_parameter_supported`. The flow has already
|
|
101
|
+
* refused a callback with more than one `iss`.
|
|
102
|
+
*/
|
|
103
|
+
iss?: string;
|
|
96
104
|
}
|
|
97
105
|
/**
|
|
98
106
|
* What a connector must provide for {@link ExternalAuthFlow} to drive it.
|
|
@@ -125,10 +133,12 @@ export interface ExternalProvider {
|
|
|
125
133
|
/**
|
|
126
134
|
* Exchanges the authorization code and returns the verified, normalized
|
|
127
135
|
* profile. Implementations must validate the id_token `nonce` against
|
|
128
|
-
* `input.nonce` when one was sent.
|
|
136
|
+
* `input.nonce` when one was sent, and should check `input.iss` before the
|
|
137
|
+
* exchange as {@link ExternalProfileInput.iss} describes.
|
|
129
138
|
*
|
|
130
139
|
* @throws {ExternalAuthError} `provider_error` when the exchange or profile
|
|
131
|
-
* fetch fails, `nonce_mismatch` when the id_token nonce does not match
|
|
140
|
+
* fetch fails, `nonce_mismatch` when the id_token nonce does not match,
|
|
141
|
+
* `invalid_callback` when `input.iss` is refused.
|
|
132
142
|
*/
|
|
133
143
|
fetchProfile(input: ExternalProfileInput): Promise<ExternalProfile>;
|
|
134
144
|
}
|
|
@@ -22,3 +22,31 @@ export interface AuthenticationContext {
|
|
|
22
22
|
/** Truthful authentication methods; omitted when the complete set is unknown. */
|
|
23
23
|
readonly amr?: readonly string[];
|
|
24
24
|
}
|
|
25
|
+
/**
|
|
26
|
+
* The OpenID Connect authentication controls of one authorization request,
|
|
27
|
+
* validated by the authorization server and handed to the application's
|
|
28
|
+
* `authenticateUser` and `handleConsent` callbacks.
|
|
29
|
+
*
|
|
30
|
+
* The server enforces what it can verify: `prompt=none` never reaches a login
|
|
31
|
+
* or consent page, and a code is issued only when the returned authentication
|
|
32
|
+
* satisfies `prompt=login` and `max_age` (see
|
|
33
|
+
* `satisfiesAuthenticationRequest`). `acrValues` is a preference the
|
|
34
|
+
* application meets as it can; report the `acr` it actually achieved.
|
|
35
|
+
*
|
|
36
|
+
* @example
|
|
37
|
+
* ```ts
|
|
38
|
+
* const authentication: AuthenticationRequest = {
|
|
39
|
+
* prompt: ["login"],
|
|
40
|
+
* maxAge: 300,
|
|
41
|
+
* acrValues: ["urn:example:mfa"],
|
|
42
|
+
* };
|
|
43
|
+
* ```
|
|
44
|
+
*/
|
|
45
|
+
export interface AuthenticationRequest {
|
|
46
|
+
/** Requested `prompt` values, each one the server supports; empty when absent. */
|
|
47
|
+
readonly prompt: readonly string[];
|
|
48
|
+
/** Requested `max_age` in seconds, when present. */
|
|
49
|
+
readonly maxAge?: number;
|
|
50
|
+
/** Requested `acr_values` in preference order; empty when absent. */
|
|
51
|
+
readonly acrValues: readonly string[];
|
|
52
|
+
}
|
|
@@ -38,11 +38,11 @@ export interface AuthorizationCode<Client extends ClientInterface, User, Scope e
|
|
|
38
38
|
* @see https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.1
|
|
39
39
|
*/
|
|
40
40
|
export interface AuthorizeParameters {
|
|
41
|
-
/** Raw space-delimited ACR preferences;
|
|
41
|
+
/** Raw space-delimited ACR preferences; `authenticateUser` receives them validated. */
|
|
42
42
|
acrValues?: string;
|
|
43
|
-
/** Raw maximum authentication age in seconds;
|
|
43
|
+
/** Raw maximum authentication age in seconds; the authorize endpoint validates and enforces it. */
|
|
44
44
|
maxAge?: string;
|
|
45
|
-
/** Raw prompt values;
|
|
45
|
+
/** Raw prompt values; the authorize endpoint validates them and enforces `none`, `login` and `consent`. */
|
|
46
46
|
prompt?: string;
|
|
47
47
|
/** Must be "code" for authorization code flow. */
|
|
48
48
|
responseType?: string;
|
|
@@ -4,12 +4,14 @@
|
|
|
4
4
|
* Includes the RFC 6750 Section 3.1 bearer-token error codes
|
|
5
5
|
* (`invalid_token`, `insufficient_scope`) emitted by resource servers, in
|
|
6
6
|
* addition to the RFC 6749 Section 5.2 / Section 4.1.2.1 codes and the RFC
|
|
7
|
-
* 8628 device-flow codes.
|
|
7
|
+
* 8628 device-flow codes and the OpenID Connect Core §3.1.2.6
|
|
8
|
+
* authentication-error codes.
|
|
8
9
|
*
|
|
9
10
|
* @see https://datatracker.ietf.org/doc/html/rfc6749#section-5.2
|
|
10
11
|
* @see https://datatracker.ietf.org/doc/html/rfc6750#section-3.1
|
|
12
|
+
* @see https://openid.net/specs/openid-connect-core-1_0.html#AuthError
|
|
11
13
|
*/
|
|
12
|
-
export type OAuth2ErrorCode = "invalid_request" | "invalid_client" | "invalid_grant" | "unauthorized_client" | "unsupported_grant_type" | "unsupported_response_type" | "unsupported_token_type" | "invalid_scope" | "invalid_token" | "insufficient_scope" | "access_denied" | "server_error" | "temporarily_unavailable" | "authorization_pending" | "slow_down" | "expired_token";
|
|
14
|
+
export type OAuth2ErrorCode = "invalid_request" | "invalid_client" | "invalid_grant" | "unauthorized_client" | "unsupported_grant_type" | "unsupported_response_type" | "unsupported_token_type" | "invalid_scope" | "invalid_token" | "insufficient_scope" | "access_denied" | "server_error" | "temporarily_unavailable" | "authorization_pending" | "slow_down" | "expired_token" | "login_required" | "consent_required" | "interaction_required" | "account_selection_required";
|
|
13
15
|
/**
|
|
14
16
|
* Standard OAuth2 error response.
|
|
15
17
|
*
|
|
@@ -74,6 +76,10 @@ export interface AuthorizationServerMetadata {
|
|
|
74
76
|
id_token_signing_alg_values_supported?: string[];
|
|
75
77
|
/** Subject identifier types supported (OIDC Discovery). */
|
|
76
78
|
subject_types_supported?: string[];
|
|
79
|
+
/** `prompt` values the authorization endpoint honors (OIDC Prompt Create). */
|
|
80
|
+
prompt_values_supported?: string[];
|
|
81
|
+
/** Authentication context class references the server can achieve (OIDC Discovery). */
|
|
82
|
+
acr_values_supported?: string[];
|
|
77
83
|
}
|
|
78
84
|
/**
|
|
79
85
|
* Device authorization response as defined in RFC 8628.
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { AuthenticationContext, AuthenticationRequest } from "../models/authentication.ts";
|
|
2
|
+
/** The `prompt` values the authorization server enforces on its own. */
|
|
3
|
+
export declare const DEFAULT_PROMPT_VALUES_SUPPORTED: readonly string[];
|
|
4
|
+
/**
|
|
5
|
+
* What an `authenticateUser` callback reports about the authentication behind
|
|
6
|
+
* the user it returns, as {@linkcode satisfiesAuthenticationRequest} reads it.
|
|
7
|
+
*/
|
|
8
|
+
export interface AuthenticationOutcome {
|
|
9
|
+
/** The verified authentication event, when known. */
|
|
10
|
+
authenticationContext?: AuthenticationContext;
|
|
11
|
+
/**
|
|
12
|
+
* `true` only when the user actively authenticated in response to this
|
|
13
|
+
* authorization request, which is what `prompt=login` and `max_age=0` ask
|
|
14
|
+
* for. A session that merely exists, or was refreshed, is not that.
|
|
15
|
+
*/
|
|
16
|
+
reauthenticated?: boolean;
|
|
17
|
+
}
|
|
18
|
+
export declare function assertSupportedValues(name: string, values: readonly string[]): readonly string[];
|
|
19
|
+
/**
|
|
20
|
+
* Validates the `prompt`, `max_age` and `acr_values` of an authorization
|
|
21
|
+
* request. Parameters sent without a value are treated as omitted (OIDC Core
|
|
22
|
+
* §3.1.2.1).
|
|
23
|
+
*
|
|
24
|
+
* @throws {InvalidRequestError} For a repeated parameter, a `prompt` value not
|
|
25
|
+
* in `promptValuesSupported`, `none` combined with another `prompt` value, or
|
|
26
|
+
* a `max_age` that is not a non-negative integer.
|
|
27
|
+
*/
|
|
28
|
+
export declare function parseAuthenticationRequest(params: URLSearchParams, promptValuesSupported: readonly string[]): AuthenticationRequest;
|
|
29
|
+
/**
|
|
30
|
+
* Why `outcome` does not satisfy `request`, as an `error_description`, or
|
|
31
|
+
* `undefined` when it does.
|
|
32
|
+
*/
|
|
33
|
+
export declare function authenticationShortfall(request: AuthenticationRequest, outcome: AuthenticationOutcome, now: number): string | undefined;
|
|
34
|
+
/**
|
|
35
|
+
* Whether an authentication meets the request's `prompt=login` and `max_age`
|
|
36
|
+
* controls — the check the authorization server applies to what
|
|
37
|
+
* `authenticateUser` returns before it issues a code. Call it from
|
|
38
|
+
* `authenticateUser` with the session's event to decide whether to send the
|
|
39
|
+
* user to sign in again instead of returning them.
|
|
40
|
+
*
|
|
41
|
+
* `prompt=login` and `max_age=0`, which OIDC Core makes equivalent, need
|
|
42
|
+
* `reauthenticated: true`. Any `max_age` needs a recorded `auth_time`, since
|
|
43
|
+
* the ID token must carry one, never more than a minute in the future. Above
|
|
44
|
+
* zero, it must also be no older than `max_age` seconds, whether or not the
|
|
45
|
+
* user re-authenticated. `acr_values` is a preference and never fails the
|
|
46
|
+
* check.
|
|
47
|
+
*
|
|
48
|
+
* @param now The current time in epoch milliseconds. Defaults to `Date.now()`.
|
|
49
|
+
*
|
|
50
|
+
* @example
|
|
51
|
+
* ```ts
|
|
52
|
+
* import { satisfiesAuthenticationRequest } from "@udibo/oauth2/server";
|
|
53
|
+
*
|
|
54
|
+
* const fresh = satisfiesAuthenticationRequest(
|
|
55
|
+
* { prompt: [], maxAge: 300, acrValues: [] },
|
|
56
|
+
* { authenticationContext: { auth_time: Math.floor(Date.now() / 1000) } },
|
|
57
|
+
* );
|
|
58
|
+
* ```
|
|
59
|
+
*/
|
|
60
|
+
export declare function satisfiesAuthenticationRequest(request: AuthenticationRequest, outcome: AuthenticationOutcome, now?: number): boolean;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { InvalidRequestError } from "../errors.js";
|
|
2
|
+
/** The `prompt` values the authorization server enforces on its own. */
|
|
3
|
+
export const DEFAULT_PROMPT_VALUES_SUPPORTED = Object.freeze(["none", "login", "consent"]);
|
|
4
|
+
const MAX_AUTH_TIME_SKEW_SECONDS = 60;
|
|
5
|
+
function spaceDelimited(value) {
|
|
6
|
+
return [...new Set((value ?? "").split(" ").filter(Boolean))];
|
|
7
|
+
}
|
|
8
|
+
export function assertSupportedValues(name, values) {
|
|
9
|
+
if (values.some((value) => typeof value !== "string" || !/^[\x21-\x7e]+$/.test(value))) {
|
|
10
|
+
throw new TypeError(`${name} entries must be non-empty strings of visible ASCII without spaces`);
|
|
11
|
+
}
|
|
12
|
+
return Object.freeze([...new Set(values)]);
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Validates the `prompt`, `max_age` and `acr_values` of an authorization
|
|
16
|
+
* request. Parameters sent without a value are treated as omitted (OIDC Core
|
|
17
|
+
* §3.1.2.1).
|
|
18
|
+
*
|
|
19
|
+
* @throws {InvalidRequestError} For a repeated parameter, a `prompt` value not
|
|
20
|
+
* in `promptValuesSupported`, `none` combined with another `prompt` value, or
|
|
21
|
+
* a `max_age` that is not a non-negative integer.
|
|
22
|
+
*/
|
|
23
|
+
export function parseAuthenticationRequest(params, promptValuesSupported) {
|
|
24
|
+
for (const name of ["prompt", "max_age", "acr_values"]) {
|
|
25
|
+
if (params.getAll(name).length > 1) {
|
|
26
|
+
throw new InvalidRequestError(`${name} parameter must not be repeated`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
const prompt = spaceDelimited(params.get("prompt"));
|
|
30
|
+
if (prompt.some((value) => !promptValuesSupported.includes(value))) {
|
|
31
|
+
throw new InvalidRequestError("prompt value not supported");
|
|
32
|
+
}
|
|
33
|
+
if (prompt.includes("none") && prompt.length > 1) {
|
|
34
|
+
throw new InvalidRequestError("prompt=none cannot be combined with other prompt values");
|
|
35
|
+
}
|
|
36
|
+
const rawMaxAge = params.get("max_age") || undefined;
|
|
37
|
+
if (rawMaxAge !== undefined &&
|
|
38
|
+
(!/^\d+$/.test(rawMaxAge) || !Number.isSafeInteger(Number(rawMaxAge)))) {
|
|
39
|
+
throw new InvalidRequestError("max_age must be a non-negative integer");
|
|
40
|
+
}
|
|
41
|
+
return Object.freeze({
|
|
42
|
+
prompt: Object.freeze(prompt),
|
|
43
|
+
...(rawMaxAge === undefined ? {} : { maxAge: Number(rawMaxAge) }),
|
|
44
|
+
acrValues: Object.freeze(spaceDelimited(params.get("acr_values"))),
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Why `outcome` does not satisfy `request`, as an `error_description`, or
|
|
49
|
+
* `undefined` when it does.
|
|
50
|
+
*/
|
|
51
|
+
export function authenticationShortfall(request, outcome, now) {
|
|
52
|
+
const reauthenticated = outcome.reauthenticated === true;
|
|
53
|
+
if (request.prompt.includes("login") && !reauthenticated) {
|
|
54
|
+
return "prompt=login requires the user to authenticate again";
|
|
55
|
+
}
|
|
56
|
+
if (request.maxAge === 0 && !reauthenticated) {
|
|
57
|
+
return "max_age=0 requires the user to authenticate again";
|
|
58
|
+
}
|
|
59
|
+
if (request.maxAge === undefined)
|
|
60
|
+
return undefined;
|
|
61
|
+
const authTime = outcome.authenticationContext?.auth_time;
|
|
62
|
+
if (typeof authTime !== "number" || !Number.isFinite(authTime)) {
|
|
63
|
+
return "max_age requires the time the user authenticated";
|
|
64
|
+
}
|
|
65
|
+
const age = Math.floor(now / 1000) - authTime;
|
|
66
|
+
if (age < -MAX_AUTH_TIME_SKEW_SECONDS) {
|
|
67
|
+
return "the user's authentication time is in the future";
|
|
68
|
+
}
|
|
69
|
+
if (request.maxAge > 0 && age > request.maxAge) {
|
|
70
|
+
return "the user's last authentication is older than max_age";
|
|
71
|
+
}
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Whether an authentication meets the request's `prompt=login` and `max_age`
|
|
76
|
+
* controls — the check the authorization server applies to what
|
|
77
|
+
* `authenticateUser` returns before it issues a code. Call it from
|
|
78
|
+
* `authenticateUser` with the session's event to decide whether to send the
|
|
79
|
+
* user to sign in again instead of returning them.
|
|
80
|
+
*
|
|
81
|
+
* `prompt=login` and `max_age=0`, which OIDC Core makes equivalent, need
|
|
82
|
+
* `reauthenticated: true`. Any `max_age` needs a recorded `auth_time`, since
|
|
83
|
+
* the ID token must carry one, never more than a minute in the future. Above
|
|
84
|
+
* zero, it must also be no older than `max_age` seconds, whether or not the
|
|
85
|
+
* user re-authenticated. `acr_values` is a preference and never fails the
|
|
86
|
+
* check.
|
|
87
|
+
*
|
|
88
|
+
* @param now The current time in epoch milliseconds. Defaults to `Date.now()`.
|
|
89
|
+
*
|
|
90
|
+
* @example
|
|
91
|
+
* ```ts
|
|
92
|
+
* import { satisfiesAuthenticationRequest } from "@udibo/oauth2/server";
|
|
93
|
+
*
|
|
94
|
+
* const fresh = satisfiesAuthenticationRequest(
|
|
95
|
+
* { prompt: [], maxAge: 300, acrValues: [] },
|
|
96
|
+
* { authenticationContext: { auth_time: Math.floor(Date.now() / 1000) } },
|
|
97
|
+
* );
|
|
98
|
+
* ```
|
|
99
|
+
*/
|
|
100
|
+
export function satisfiesAuthenticationRequest(request, outcome, now = Date.now()) {
|
|
101
|
+
return authenticationShortfall(request, outcome, now) === undefined;
|
|
102
|
+
}
|