@tdacorp/identity-client 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/LICENSE +21 -0
- package/README.md +299 -0
- package/dist/chunk-KLXEPP4S.js +80 -0
- package/dist/chunk-VJSMXIZB.js +320 -0
- package/dist/index.cjs +357 -0
- package/dist/index.d.cts +80 -0
- package/dist/index.d.ts +80 -0
- package/dist/index.js +28 -0
- package/dist/next.cjs +461 -0
- package/dist/next.d.cts +85 -0
- package/dist/next.d.ts +85 -0
- package/dist/next.js +129 -0
- package/dist/sealed.cjs +101 -0
- package/dist/sealed.d.cts +95 -0
- package/dist/sealed.d.ts +95 -0
- package/dist/sealed.js +10 -0
- package/dist/verify-BiGhwaIz.d.cts +306 -0
- package/dist/verify-BiGhwaIz.d.ts +306 -0
- package/package.json +80 -0
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import { RolesClaim } from '@tdacorp/identity-authz';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A successful OAuth2/OIDC token response, camelCased from the wire's
|
|
5
|
+
* snake_case (RFC 6749 §5.1).
|
|
6
|
+
*/
|
|
7
|
+
interface TokenSet {
|
|
8
|
+
accessToken: string;
|
|
9
|
+
tokenType: string;
|
|
10
|
+
refreshToken?: string;
|
|
11
|
+
/** Present only when the request's scope included `openid`. */
|
|
12
|
+
idToken?: string;
|
|
13
|
+
/** Lifetime of `accessToken` in seconds from the time of issuance, when
|
|
14
|
+
* the server reports one. */
|
|
15
|
+
expiresIn?: number;
|
|
16
|
+
/** The scope actually granted, space-separated -- may be narrower than
|
|
17
|
+
* what was requested; absent when the server considers it unchanged. */
|
|
18
|
+
scope?: string;
|
|
19
|
+
}
|
|
20
|
+
/** Thrown by `exchangeAuthorizationCode` and `clientCredentialsGrant` on any
|
|
21
|
+
* non-success response. Both are one-shot actions with no live session to
|
|
22
|
+
* protect from a bad classification, unlike `refreshTokens` below, so a
|
|
23
|
+
* plain throw is enough: the caller has nothing to preserve either way. */
|
|
24
|
+
declare class TokenRequestError extends Error {
|
|
25
|
+
readonly status?: number;
|
|
26
|
+
readonly error?: string;
|
|
27
|
+
readonly errorDescription?: string;
|
|
28
|
+
constructor(details: {
|
|
29
|
+
status?: number;
|
|
30
|
+
error?: string;
|
|
31
|
+
errorDescription?: string;
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
/** Options for `exchangeAuthorizationCode`. */
|
|
35
|
+
interface AuthorizationCodeExchangeOptions {
|
|
36
|
+
/** The identity server's issuer URL, used to look up `token_endpoint`. */
|
|
37
|
+
issuer: string;
|
|
38
|
+
clientId: string;
|
|
39
|
+
/** Omit for a public client authenticating via PKCE alone. */
|
|
40
|
+
clientSecret?: string;
|
|
41
|
+
/** The `code` query param from the authorization callback. */
|
|
42
|
+
code: string;
|
|
43
|
+
/** Must exactly match the `redirect_uri` used on the authorization
|
|
44
|
+
* request (RFC 6749 §4.1.3). */
|
|
45
|
+
redirectUri: string;
|
|
46
|
+
/** The PKCE verifier generated (and kept) alongside the code challenge
|
|
47
|
+
* sent on the authorization request; see `generateCodeVerifier`. */
|
|
48
|
+
codeVerifier: string;
|
|
49
|
+
}
|
|
50
|
+
/** RFC 6749 §4.1.3 / RFC 7636 §4.5: exchanges an authorization code for a
|
|
51
|
+
* token set. Throws `TokenRequestError` on any non-success response. */
|
|
52
|
+
declare function exchangeAuthorizationCode(options: AuthorizationCodeExchangeOptions): Promise<TokenSet>;
|
|
53
|
+
/** Options for `clientCredentialsGrant`. */
|
|
54
|
+
interface ClientCredentialsGrantOptions {
|
|
55
|
+
/** The identity server's issuer URL, used to look up `token_endpoint`. */
|
|
56
|
+
issuer: string;
|
|
57
|
+
clientId: string;
|
|
58
|
+
/** Required -- this grant has no end user and no PKCE verifier, so the
|
|
59
|
+
* client must authenticate with its own confidential secret. */
|
|
60
|
+
clientSecret: string;
|
|
61
|
+
/** Space-separated scopes to request for the resulting token. */
|
|
62
|
+
scope?: string;
|
|
63
|
+
}
|
|
64
|
+
/** RFC 6749 §4.4: mints a token with no end user, for service-to-service
|
|
65
|
+
* calls. Always confidential -- `clientSecret` is required, not optional,
|
|
66
|
+
* because this grant has no PKCE verifier to authenticate a public client
|
|
67
|
+
* with. Throws `TokenRequestError` on any non-success response. */
|
|
68
|
+
declare function clientCredentialsGrant(options: ClientCredentialsGrantOptions): Promise<TokenSet>;
|
|
69
|
+
/** Options for `refreshTokens`. */
|
|
70
|
+
interface RefreshTokensOptions {
|
|
71
|
+
/** The identity server's issuer URL, used to look up `token_endpoint`. */
|
|
72
|
+
issuer: string;
|
|
73
|
+
clientId: string;
|
|
74
|
+
/** Omit for a public client authenticating via PKCE alone. */
|
|
75
|
+
clientSecret?: string;
|
|
76
|
+
/** The refresh token to redeem. */
|
|
77
|
+
refreshToken: string;
|
|
78
|
+
}
|
|
79
|
+
type TokenRefreshOutcome = {
|
|
80
|
+
outcome: 'success';
|
|
81
|
+
tokens: TokenSet;
|
|
82
|
+
}
|
|
83
|
+
/** The refresh token's status is still unknown. The caller must deny the
|
|
84
|
+
* request this refresh was gating, but must NOT clear the refresh token
|
|
85
|
+
* or any session built on it -- see this function's own docblock. */
|
|
86
|
+
| {
|
|
87
|
+
outcome: 'transient';
|
|
88
|
+
error: unknown;
|
|
89
|
+
}
|
|
90
|
+
/** The refresh token is confirmed dead. Safe, and only now safe, for the
|
|
91
|
+
* caller to clear it. */
|
|
92
|
+
| {
|
|
93
|
+
outcome: 'terminal';
|
|
94
|
+
error: TokenRequestError;
|
|
95
|
+
};
|
|
96
|
+
/**
|
|
97
|
+
* RFC 6749 §6: redeems a refresh token for a new token set, classifying
|
|
98
|
+
* failure as `transient` (retry later, credential still unknown) or
|
|
99
|
+
* `terminal` (the refresh token is confirmed dead) instead of collapsing
|
|
100
|
+
* both into one rejection.
|
|
101
|
+
*
|
|
102
|
+
* WHY THIS DISTINCTION EXISTS, AND WHY IT IS NOT OPTIONAL
|
|
103
|
+
*
|
|
104
|
+
* An infrastructure hiccup must never be treated the same as a rejected
|
|
105
|
+
* credential. A caller that clears a session/cookie on ANY refresh failure
|
|
106
|
+
* will force-log-out every one of its users during a transient outage, or
|
|
107
|
+
* the moment its own client secret is rotated or its client is temporarily
|
|
108
|
+
* disabled -- neither of those says anything about whether the refresh
|
|
109
|
+
* token itself is still good. This function classifies failures against the
|
|
110
|
+
* identity server's own status-code contract for exactly that reason: only
|
|
111
|
+
* a response that is actually a verdict on the refresh token gets to clear
|
|
112
|
+
* it.
|
|
113
|
+
*
|
|
114
|
+
* CLASSIFICATION
|
|
115
|
+
*
|
|
116
|
+
* - `transient`: a 408/429/5xx HTTP status, `fetch` itself throwing (DNS
|
|
117
|
+
* failure, connection refused, TLS error, timeout), or a 401 response. A
|
|
118
|
+
* 401 here is the server rejecting THIS CALLER's own client credentials
|
|
119
|
+
* (`invalid_client`) -- a verdict on the caller, not on the refresh token
|
|
120
|
+
* being redeemed, so the token's status is still unknown just like the
|
|
121
|
+
* other cases in this bucket.
|
|
122
|
+
* - `terminal`: a 400 response with `error: "invalid_grant"` -- RFC 6749
|
|
123
|
+
* §5.2's own code for "the ... refresh token is invalid, expired,
|
|
124
|
+
* revoked". This is the only shape that is actually the server SAYING the
|
|
125
|
+
* refresh token itself is dead.
|
|
126
|
+
* - Anything else (an unrecognised 4xx, a 2xx with a malformed body) defaults
|
|
127
|
+
* to `transient`. This is the safe direction, not a shrug: misclassifying
|
|
128
|
+
* a live refresh token as terminal tells the caller to destroy a working
|
|
129
|
+
* credential and force a real user to sign in again, while misclassifying
|
|
130
|
+
* a dead one as transient only delays cleanup until the next refresh
|
|
131
|
+
* attempt, which is self-correcting and costs nothing but a little
|
|
132
|
+
* staleness.
|
|
133
|
+
*/
|
|
134
|
+
declare function refreshTokens(options: RefreshTokensOptions): Promise<TokenRefreshOutcome>;
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* What a verified TDACorp Identity access token actually is: a token minted
|
|
138
|
+
* for an end user, or a token minted for a machine with none.
|
|
139
|
+
*
|
|
140
|
+
* Precedent: Clerk's backend SDK returns a discriminated `tokenType` /
|
|
141
|
+
* `acceptsToken` object from its own token verification rather than one flat
|
|
142
|
+
* claims bag, for the same reason this type exists -- a caller that reads a
|
|
143
|
+
* property off the wrong variant should get a compile error, not `undefined`
|
|
144
|
+
* read as "no roles" or a machine's `clientId` misread as a user's `sub`.
|
|
145
|
+
*
|
|
146
|
+
* The concrete reason this matters here: this identity server's
|
|
147
|
+
* `client_credentials` grant mints access tokens with no end user and no
|
|
148
|
+
* `roles` claim at all -- there is no subject for a roles claim to describe.
|
|
149
|
+
* Before this type existed, `verifyAccessToken` returned one flat claims
|
|
150
|
+
* object for both cases, so a caller checking `.sub` on a machine token
|
|
151
|
+
* silently got `undefined` instead of a type error telling it this branch
|
|
152
|
+
* has no subject.
|
|
153
|
+
*/
|
|
154
|
+
type VerifiedAccessToken = {
|
|
155
|
+
kind: 'user';
|
|
156
|
+
sub: string;
|
|
157
|
+
/** Present only when the token was minted with the `roles` scope.
|
|
158
|
+
* Pass to `permits()` from `@tdacorp/identity-authz` to decide a
|
|
159
|
+
* permission locally. */
|
|
160
|
+
roles?: RolesClaim;
|
|
161
|
+
claims: Record<string, unknown>;
|
|
162
|
+
} | {
|
|
163
|
+
kind: 'machine';
|
|
164
|
+
clientId: string;
|
|
165
|
+
claims: Record<string, unknown>;
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Allowed clock skew between this process and the identity server, applied
|
|
170
|
+
* via jose's `clockTolerance`.
|
|
171
|
+
*
|
|
172
|
+
* 60 seconds, matching Auth0's own documented default leeway for ID token
|
|
173
|
+
* validation (60000ms, e.g. Auth0's Lock.swift and Auth0.js configuration
|
|
174
|
+
* docs) and the conventional default several JWT libraries ship
|
|
175
|
+
* (`jsonwebtoken`'s own README recommendation, Spring Security's
|
|
176
|
+
* `JwtTimestampValidator`). Clerk's backend SDK is tighter, defaulting
|
|
177
|
+
* `clockSkewInMs` to 5000 (5s) -- verified against Clerk's own docs. 60s is
|
|
178
|
+
* chosen over Clerk's tighter figure deliberately: this is a first-party,
|
|
179
|
+
* low-latency deployment (the identity server and every relying party share
|
|
180
|
+
* infrastructure-grade NTP-synced clocks), so the risk a looser tolerance
|
|
181
|
+
* trades away -- a stolen, still-live token being replayed slightly past its
|
|
182
|
+
* `exp` -- is small next to the cost of a false rejection from ordinary
|
|
183
|
+
* clock drift, and 60s is not a novel number invented for this package: it
|
|
184
|
+
* is the figure Auth0 itself ships as a default.
|
|
185
|
+
*/
|
|
186
|
+
declare const CLOCK_SKEW_TOLERANCE_SECONDS = 60;
|
|
187
|
+
/**
|
|
188
|
+
* Why `verifyIdToken`/`verifyAccessToken` could not produce a verified
|
|
189
|
+
* claims payload. Every value here is a property of the TOKEN itself, not a
|
|
190
|
+
* caller programming error -- a missing `options.issuer`/`options.audience`
|
|
191
|
+
* throws instead (see `requireConfig`).
|
|
192
|
+
*
|
|
193
|
+
* - `'expired'`: the token's `exp` has passed, even after
|
|
194
|
+
* `CLOCK_SKEW_TOLERANCE_SECONDS` of leeway.
|
|
195
|
+
* - `'invalid-signature'`: the signature does not verify against any key in
|
|
196
|
+
* the issuer's JWKS (wrong key, tampered token, or an ambiguous match).
|
|
197
|
+
* - `'invalid-issuer'`: the token's `iss` does not match `options.issuer`.
|
|
198
|
+
* - `'invalid-audience'`: the token's `aud` does not match `options.audience`.
|
|
199
|
+
* - `'unauthorized-party'`: `options.authorizedParties` was given and the
|
|
200
|
+
* token's `azp` (or single-string `aud` fallback) is not in it -- see
|
|
201
|
+
* `VerifyTokenOptions.authorizedParties`'s own docblock for why this
|
|
202
|
+
* check exists alongside the plain audience check.
|
|
203
|
+
* - `'malformed-claims'`: the token verified but is missing a claim this
|
|
204
|
+
* package requires (e.g. `sub` on an id_token).
|
|
205
|
+
* - `'malformed-token'`: the token is not a well-formed JWT at all.
|
|
206
|
+
* - `'jwks-unavailable'`: the issuer's discovery document or JWKS could not
|
|
207
|
+
* be fetched -- infrastructure, not a verdict on the token.
|
|
208
|
+
*/
|
|
209
|
+
type TokenVerificationErrorCode = 'expired' | 'invalid-signature' | 'invalid-issuer' | 'invalid-audience' | 'unauthorized-party' | 'malformed-claims' | 'malformed-token' | 'jwks-unavailable';
|
|
210
|
+
/** One verification failure: a stable `code` to branch on, plus a
|
|
211
|
+
* human-readable `message` for logs. `verifyIdToken`/`verifyAccessToken`
|
|
212
|
+
* return a list of these rather than throwing -- see `VerificationResult`. */
|
|
213
|
+
interface TokenVerificationError {
|
|
214
|
+
code: TokenVerificationErrorCode;
|
|
215
|
+
message: string;
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* The outcome of `verifyIdToken`/`verifyAccessToken`: either the verified,
|
|
219
|
+
* typed claims (`success: true`), or the reasons verification failed
|
|
220
|
+
* (`success: false`). A discriminated union rather than a thrown error, on
|
|
221
|
+
* the same reasoning as `Permit` in `@tdacorp/identity-authz`: an expected
|
|
222
|
+
* verification failure (expired, bad signature, wrong audience) is data a
|
|
223
|
+
* caller branches on, not an exceptional condition -- only a genuine
|
|
224
|
+
* programming error (missing `options.issuer`/`options.audience`) throws.
|
|
225
|
+
*
|
|
226
|
+
* @example
|
|
227
|
+
* ```ts
|
|
228
|
+
* const result = await verifyIdToken(idToken, { issuer, audience })
|
|
229
|
+
* if (result.success) {
|
|
230
|
+
* console.log(result.data.sub)
|
|
231
|
+
* } else {
|
|
232
|
+
* console.warn(result.errors.map((e) => e.code))
|
|
233
|
+
* }
|
|
234
|
+
* ```
|
|
235
|
+
*/
|
|
236
|
+
type VerificationResult<T> = {
|
|
237
|
+
success: true;
|
|
238
|
+
data: T;
|
|
239
|
+
} | {
|
|
240
|
+
success: false;
|
|
241
|
+
errors: TokenVerificationError[];
|
|
242
|
+
};
|
|
243
|
+
/** Configuration shared by `verifyIdToken` and `verifyAccessToken`: which
|
|
244
|
+
* issuer and audience a token must match, plus an optional
|
|
245
|
+
* `authorizedParties` allowlist (see that field's own docblock below). */
|
|
246
|
+
interface VerifyTokenOptions {
|
|
247
|
+
issuer: string;
|
|
248
|
+
audience: string;
|
|
249
|
+
/**
|
|
250
|
+
* Origins/client ids this caller trusts a token to have been issued to,
|
|
251
|
+
* checked against the token's `azp` claim when present, falling back to a
|
|
252
|
+
* string `aud` otherwise.
|
|
253
|
+
*
|
|
254
|
+
* WHY `azp` FIRST: this identity server always sets `azp` to the
|
|
255
|
+
* requesting client's own id, even for an access token whose `aud` was
|
|
256
|
+
* overridden to an RFC 8707 resource indicator -- `azp` keeps naming who
|
|
257
|
+
* the token was issued to regardless of what `aud` also claims to be valid
|
|
258
|
+
* at. `aud` is only a safe fallback when it is a single string naming one
|
|
259
|
+
* party; this package does not attempt the allowlist check against a
|
|
260
|
+
* multi-valued `aud`.
|
|
261
|
+
*
|
|
262
|
+
* WHY THIS CHECK EXISTS AT ALL: every TDACorp product shares the
|
|
263
|
+
* `tdacorp.in` registrable domain, and this server's `aud`/`azp` values
|
|
264
|
+
* are opaque client ids, not origins that a browser's same-origin policy
|
|
265
|
+
* already separates. A token minted for one product is not otherwise
|
|
266
|
+
* prevented from being replayed against another product that shares this
|
|
267
|
+
* issuer -- both the plain `audience` check above and this allowlist are
|
|
268
|
+
* needed, and they check different things: one asks whether the token is
|
|
269
|
+
* FOR this resource server, the other asks whether it was issued THROUGH
|
|
270
|
+
* a client this caller has chosen to trust.
|
|
271
|
+
*/
|
|
272
|
+
authorizedParties?: string[];
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* The verified claims of an OIDC id_token, as returned by `verifyIdToken`.
|
|
276
|
+
*
|
|
277
|
+
* Only the claims every id_token carries per OpenID Connect Core 1.0 §2 are
|
|
278
|
+
* typed here (`sub`, `iss`, `aud`, `exp`, `iat`); anything else the identity
|
|
279
|
+
* server includes (e.g. `email`, `name`) still comes through on the object,
|
|
280
|
+
* covered by the index signature, just not individually typed.
|
|
281
|
+
*/
|
|
282
|
+
interface IdTokenClaims extends Record<string, unknown> {
|
|
283
|
+
sub: string;
|
|
284
|
+
iss: string;
|
|
285
|
+
aud: string | string[];
|
|
286
|
+
exp: number;
|
|
287
|
+
iat: number;
|
|
288
|
+
}
|
|
289
|
+
/** Verifies an OIDC id_token's signature, issuer, audience and expiry (with
|
|
290
|
+
* the clock-skew tolerance documented on `CLOCK_SKEW_TOLERANCE_SECONDS`).
|
|
291
|
+
* Never throws for an expected verification failure -- expired, bad
|
|
292
|
+
* signature, wrong issuer/audience -- returning `{ success: false, errors
|
|
293
|
+
* }` instead. Throws only for missing required config (`options.issuer` /
|
|
294
|
+
* `options.audience`), which is a programming error, not a property of the
|
|
295
|
+
* token. */
|
|
296
|
+
declare function verifyIdToken(idToken: string, options: VerifyTokenOptions): Promise<VerificationResult<IdTokenClaims>>;
|
|
297
|
+
/** Verifies an access token the same way `verifyIdToken` does, then shapes
|
|
298
|
+
* the result as a `VerifiedAccessToken` (see that type's own docblock for
|
|
299
|
+
* why user and machine tokens are a discriminated union rather than one
|
|
300
|
+
* flat claims bag) with the `roles` claim, if present, typed as
|
|
301
|
+
* `RolesClaim` from `@tdacorp/identity-authz` rather than left as an
|
|
302
|
+
* unparsed field the caller has to know to reach for. Same throw/return
|
|
303
|
+
* contract as `verifyIdToken`. */
|
|
304
|
+
declare function verifyAccessToken(accessToken: string, options: VerifyTokenOptions): Promise<VerificationResult<VerifiedAccessToken>>;
|
|
305
|
+
|
|
306
|
+
export { type AuthorizationCodeExchangeOptions as A, CLOCK_SKEW_TOLERANCE_SECONDS as C, type IdTokenClaims as I, type RefreshTokensOptions as R, type TokenRefreshOutcome as T, type VerificationResult as V, type ClientCredentialsGrantOptions as a, TokenRequestError as b, type TokenSet as c, type TokenVerificationError as d, type TokenVerificationErrorCode as e, type VerifiedAccessToken as f, type VerifyTokenOptions as g, clientCredentialsGrant as h, exchangeAuthorizationCode as i, verifyIdToken as j, refreshTokens as r, verifyAccessToken as v };
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import { RolesClaim } from '@tdacorp/identity-authz';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A successful OAuth2/OIDC token response, camelCased from the wire's
|
|
5
|
+
* snake_case (RFC 6749 §5.1).
|
|
6
|
+
*/
|
|
7
|
+
interface TokenSet {
|
|
8
|
+
accessToken: string;
|
|
9
|
+
tokenType: string;
|
|
10
|
+
refreshToken?: string;
|
|
11
|
+
/** Present only when the request's scope included `openid`. */
|
|
12
|
+
idToken?: string;
|
|
13
|
+
/** Lifetime of `accessToken` in seconds from the time of issuance, when
|
|
14
|
+
* the server reports one. */
|
|
15
|
+
expiresIn?: number;
|
|
16
|
+
/** The scope actually granted, space-separated -- may be narrower than
|
|
17
|
+
* what was requested; absent when the server considers it unchanged. */
|
|
18
|
+
scope?: string;
|
|
19
|
+
}
|
|
20
|
+
/** Thrown by `exchangeAuthorizationCode` and `clientCredentialsGrant` on any
|
|
21
|
+
* non-success response. Both are one-shot actions with no live session to
|
|
22
|
+
* protect from a bad classification, unlike `refreshTokens` below, so a
|
|
23
|
+
* plain throw is enough: the caller has nothing to preserve either way. */
|
|
24
|
+
declare class TokenRequestError extends Error {
|
|
25
|
+
readonly status?: number;
|
|
26
|
+
readonly error?: string;
|
|
27
|
+
readonly errorDescription?: string;
|
|
28
|
+
constructor(details: {
|
|
29
|
+
status?: number;
|
|
30
|
+
error?: string;
|
|
31
|
+
errorDescription?: string;
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
/** Options for `exchangeAuthorizationCode`. */
|
|
35
|
+
interface AuthorizationCodeExchangeOptions {
|
|
36
|
+
/** The identity server's issuer URL, used to look up `token_endpoint`. */
|
|
37
|
+
issuer: string;
|
|
38
|
+
clientId: string;
|
|
39
|
+
/** Omit for a public client authenticating via PKCE alone. */
|
|
40
|
+
clientSecret?: string;
|
|
41
|
+
/** The `code` query param from the authorization callback. */
|
|
42
|
+
code: string;
|
|
43
|
+
/** Must exactly match the `redirect_uri` used on the authorization
|
|
44
|
+
* request (RFC 6749 §4.1.3). */
|
|
45
|
+
redirectUri: string;
|
|
46
|
+
/** The PKCE verifier generated (and kept) alongside the code challenge
|
|
47
|
+
* sent on the authorization request; see `generateCodeVerifier`. */
|
|
48
|
+
codeVerifier: string;
|
|
49
|
+
}
|
|
50
|
+
/** RFC 6749 §4.1.3 / RFC 7636 §4.5: exchanges an authorization code for a
|
|
51
|
+
* token set. Throws `TokenRequestError` on any non-success response. */
|
|
52
|
+
declare function exchangeAuthorizationCode(options: AuthorizationCodeExchangeOptions): Promise<TokenSet>;
|
|
53
|
+
/** Options for `clientCredentialsGrant`. */
|
|
54
|
+
interface ClientCredentialsGrantOptions {
|
|
55
|
+
/** The identity server's issuer URL, used to look up `token_endpoint`. */
|
|
56
|
+
issuer: string;
|
|
57
|
+
clientId: string;
|
|
58
|
+
/** Required -- this grant has no end user and no PKCE verifier, so the
|
|
59
|
+
* client must authenticate with its own confidential secret. */
|
|
60
|
+
clientSecret: string;
|
|
61
|
+
/** Space-separated scopes to request for the resulting token. */
|
|
62
|
+
scope?: string;
|
|
63
|
+
}
|
|
64
|
+
/** RFC 6749 §4.4: mints a token with no end user, for service-to-service
|
|
65
|
+
* calls. Always confidential -- `clientSecret` is required, not optional,
|
|
66
|
+
* because this grant has no PKCE verifier to authenticate a public client
|
|
67
|
+
* with. Throws `TokenRequestError` on any non-success response. */
|
|
68
|
+
declare function clientCredentialsGrant(options: ClientCredentialsGrantOptions): Promise<TokenSet>;
|
|
69
|
+
/** Options for `refreshTokens`. */
|
|
70
|
+
interface RefreshTokensOptions {
|
|
71
|
+
/** The identity server's issuer URL, used to look up `token_endpoint`. */
|
|
72
|
+
issuer: string;
|
|
73
|
+
clientId: string;
|
|
74
|
+
/** Omit for a public client authenticating via PKCE alone. */
|
|
75
|
+
clientSecret?: string;
|
|
76
|
+
/** The refresh token to redeem. */
|
|
77
|
+
refreshToken: string;
|
|
78
|
+
}
|
|
79
|
+
type TokenRefreshOutcome = {
|
|
80
|
+
outcome: 'success';
|
|
81
|
+
tokens: TokenSet;
|
|
82
|
+
}
|
|
83
|
+
/** The refresh token's status is still unknown. The caller must deny the
|
|
84
|
+
* request this refresh was gating, but must NOT clear the refresh token
|
|
85
|
+
* or any session built on it -- see this function's own docblock. */
|
|
86
|
+
| {
|
|
87
|
+
outcome: 'transient';
|
|
88
|
+
error: unknown;
|
|
89
|
+
}
|
|
90
|
+
/** The refresh token is confirmed dead. Safe, and only now safe, for the
|
|
91
|
+
* caller to clear it. */
|
|
92
|
+
| {
|
|
93
|
+
outcome: 'terminal';
|
|
94
|
+
error: TokenRequestError;
|
|
95
|
+
};
|
|
96
|
+
/**
|
|
97
|
+
* RFC 6749 §6: redeems a refresh token for a new token set, classifying
|
|
98
|
+
* failure as `transient` (retry later, credential still unknown) or
|
|
99
|
+
* `terminal` (the refresh token is confirmed dead) instead of collapsing
|
|
100
|
+
* both into one rejection.
|
|
101
|
+
*
|
|
102
|
+
* WHY THIS DISTINCTION EXISTS, AND WHY IT IS NOT OPTIONAL
|
|
103
|
+
*
|
|
104
|
+
* An infrastructure hiccup must never be treated the same as a rejected
|
|
105
|
+
* credential. A caller that clears a session/cookie on ANY refresh failure
|
|
106
|
+
* will force-log-out every one of its users during a transient outage, or
|
|
107
|
+
* the moment its own client secret is rotated or its client is temporarily
|
|
108
|
+
* disabled -- neither of those says anything about whether the refresh
|
|
109
|
+
* token itself is still good. This function classifies failures against the
|
|
110
|
+
* identity server's own status-code contract for exactly that reason: only
|
|
111
|
+
* a response that is actually a verdict on the refresh token gets to clear
|
|
112
|
+
* it.
|
|
113
|
+
*
|
|
114
|
+
* CLASSIFICATION
|
|
115
|
+
*
|
|
116
|
+
* - `transient`: a 408/429/5xx HTTP status, `fetch` itself throwing (DNS
|
|
117
|
+
* failure, connection refused, TLS error, timeout), or a 401 response. A
|
|
118
|
+
* 401 here is the server rejecting THIS CALLER's own client credentials
|
|
119
|
+
* (`invalid_client`) -- a verdict on the caller, not on the refresh token
|
|
120
|
+
* being redeemed, so the token's status is still unknown just like the
|
|
121
|
+
* other cases in this bucket.
|
|
122
|
+
* - `terminal`: a 400 response with `error: "invalid_grant"` -- RFC 6749
|
|
123
|
+
* §5.2's own code for "the ... refresh token is invalid, expired,
|
|
124
|
+
* revoked". This is the only shape that is actually the server SAYING the
|
|
125
|
+
* refresh token itself is dead.
|
|
126
|
+
* - Anything else (an unrecognised 4xx, a 2xx with a malformed body) defaults
|
|
127
|
+
* to `transient`. This is the safe direction, not a shrug: misclassifying
|
|
128
|
+
* a live refresh token as terminal tells the caller to destroy a working
|
|
129
|
+
* credential and force a real user to sign in again, while misclassifying
|
|
130
|
+
* a dead one as transient only delays cleanup until the next refresh
|
|
131
|
+
* attempt, which is self-correcting and costs nothing but a little
|
|
132
|
+
* staleness.
|
|
133
|
+
*/
|
|
134
|
+
declare function refreshTokens(options: RefreshTokensOptions): Promise<TokenRefreshOutcome>;
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* What a verified TDACorp Identity access token actually is: a token minted
|
|
138
|
+
* for an end user, or a token minted for a machine with none.
|
|
139
|
+
*
|
|
140
|
+
* Precedent: Clerk's backend SDK returns a discriminated `tokenType` /
|
|
141
|
+
* `acceptsToken` object from its own token verification rather than one flat
|
|
142
|
+
* claims bag, for the same reason this type exists -- a caller that reads a
|
|
143
|
+
* property off the wrong variant should get a compile error, not `undefined`
|
|
144
|
+
* read as "no roles" or a machine's `clientId` misread as a user's `sub`.
|
|
145
|
+
*
|
|
146
|
+
* The concrete reason this matters here: this identity server's
|
|
147
|
+
* `client_credentials` grant mints access tokens with no end user and no
|
|
148
|
+
* `roles` claim at all -- there is no subject for a roles claim to describe.
|
|
149
|
+
* Before this type existed, `verifyAccessToken` returned one flat claims
|
|
150
|
+
* object for both cases, so a caller checking `.sub` on a machine token
|
|
151
|
+
* silently got `undefined` instead of a type error telling it this branch
|
|
152
|
+
* has no subject.
|
|
153
|
+
*/
|
|
154
|
+
type VerifiedAccessToken = {
|
|
155
|
+
kind: 'user';
|
|
156
|
+
sub: string;
|
|
157
|
+
/** Present only when the token was minted with the `roles` scope.
|
|
158
|
+
* Pass to `permits()` from `@tdacorp/identity-authz` to decide a
|
|
159
|
+
* permission locally. */
|
|
160
|
+
roles?: RolesClaim;
|
|
161
|
+
claims: Record<string, unknown>;
|
|
162
|
+
} | {
|
|
163
|
+
kind: 'machine';
|
|
164
|
+
clientId: string;
|
|
165
|
+
claims: Record<string, unknown>;
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Allowed clock skew between this process and the identity server, applied
|
|
170
|
+
* via jose's `clockTolerance`.
|
|
171
|
+
*
|
|
172
|
+
* 60 seconds, matching Auth0's own documented default leeway for ID token
|
|
173
|
+
* validation (60000ms, e.g. Auth0's Lock.swift and Auth0.js configuration
|
|
174
|
+
* docs) and the conventional default several JWT libraries ship
|
|
175
|
+
* (`jsonwebtoken`'s own README recommendation, Spring Security's
|
|
176
|
+
* `JwtTimestampValidator`). Clerk's backend SDK is tighter, defaulting
|
|
177
|
+
* `clockSkewInMs` to 5000 (5s) -- verified against Clerk's own docs. 60s is
|
|
178
|
+
* chosen over Clerk's tighter figure deliberately: this is a first-party,
|
|
179
|
+
* low-latency deployment (the identity server and every relying party share
|
|
180
|
+
* infrastructure-grade NTP-synced clocks), so the risk a looser tolerance
|
|
181
|
+
* trades away -- a stolen, still-live token being replayed slightly past its
|
|
182
|
+
* `exp` -- is small next to the cost of a false rejection from ordinary
|
|
183
|
+
* clock drift, and 60s is not a novel number invented for this package: it
|
|
184
|
+
* is the figure Auth0 itself ships as a default.
|
|
185
|
+
*/
|
|
186
|
+
declare const CLOCK_SKEW_TOLERANCE_SECONDS = 60;
|
|
187
|
+
/**
|
|
188
|
+
* Why `verifyIdToken`/`verifyAccessToken` could not produce a verified
|
|
189
|
+
* claims payload. Every value here is a property of the TOKEN itself, not a
|
|
190
|
+
* caller programming error -- a missing `options.issuer`/`options.audience`
|
|
191
|
+
* throws instead (see `requireConfig`).
|
|
192
|
+
*
|
|
193
|
+
* - `'expired'`: the token's `exp` has passed, even after
|
|
194
|
+
* `CLOCK_SKEW_TOLERANCE_SECONDS` of leeway.
|
|
195
|
+
* - `'invalid-signature'`: the signature does not verify against any key in
|
|
196
|
+
* the issuer's JWKS (wrong key, tampered token, or an ambiguous match).
|
|
197
|
+
* - `'invalid-issuer'`: the token's `iss` does not match `options.issuer`.
|
|
198
|
+
* - `'invalid-audience'`: the token's `aud` does not match `options.audience`.
|
|
199
|
+
* - `'unauthorized-party'`: `options.authorizedParties` was given and the
|
|
200
|
+
* token's `azp` (or single-string `aud` fallback) is not in it -- see
|
|
201
|
+
* `VerifyTokenOptions.authorizedParties`'s own docblock for why this
|
|
202
|
+
* check exists alongside the plain audience check.
|
|
203
|
+
* - `'malformed-claims'`: the token verified but is missing a claim this
|
|
204
|
+
* package requires (e.g. `sub` on an id_token).
|
|
205
|
+
* - `'malformed-token'`: the token is not a well-formed JWT at all.
|
|
206
|
+
* - `'jwks-unavailable'`: the issuer's discovery document or JWKS could not
|
|
207
|
+
* be fetched -- infrastructure, not a verdict on the token.
|
|
208
|
+
*/
|
|
209
|
+
type TokenVerificationErrorCode = 'expired' | 'invalid-signature' | 'invalid-issuer' | 'invalid-audience' | 'unauthorized-party' | 'malformed-claims' | 'malformed-token' | 'jwks-unavailable';
|
|
210
|
+
/** One verification failure: a stable `code` to branch on, plus a
|
|
211
|
+
* human-readable `message` for logs. `verifyIdToken`/`verifyAccessToken`
|
|
212
|
+
* return a list of these rather than throwing -- see `VerificationResult`. */
|
|
213
|
+
interface TokenVerificationError {
|
|
214
|
+
code: TokenVerificationErrorCode;
|
|
215
|
+
message: string;
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* The outcome of `verifyIdToken`/`verifyAccessToken`: either the verified,
|
|
219
|
+
* typed claims (`success: true`), or the reasons verification failed
|
|
220
|
+
* (`success: false`). A discriminated union rather than a thrown error, on
|
|
221
|
+
* the same reasoning as `Permit` in `@tdacorp/identity-authz`: an expected
|
|
222
|
+
* verification failure (expired, bad signature, wrong audience) is data a
|
|
223
|
+
* caller branches on, not an exceptional condition -- only a genuine
|
|
224
|
+
* programming error (missing `options.issuer`/`options.audience`) throws.
|
|
225
|
+
*
|
|
226
|
+
* @example
|
|
227
|
+
* ```ts
|
|
228
|
+
* const result = await verifyIdToken(idToken, { issuer, audience })
|
|
229
|
+
* if (result.success) {
|
|
230
|
+
* console.log(result.data.sub)
|
|
231
|
+
* } else {
|
|
232
|
+
* console.warn(result.errors.map((e) => e.code))
|
|
233
|
+
* }
|
|
234
|
+
* ```
|
|
235
|
+
*/
|
|
236
|
+
type VerificationResult<T> = {
|
|
237
|
+
success: true;
|
|
238
|
+
data: T;
|
|
239
|
+
} | {
|
|
240
|
+
success: false;
|
|
241
|
+
errors: TokenVerificationError[];
|
|
242
|
+
};
|
|
243
|
+
/** Configuration shared by `verifyIdToken` and `verifyAccessToken`: which
|
|
244
|
+
* issuer and audience a token must match, plus an optional
|
|
245
|
+
* `authorizedParties` allowlist (see that field's own docblock below). */
|
|
246
|
+
interface VerifyTokenOptions {
|
|
247
|
+
issuer: string;
|
|
248
|
+
audience: string;
|
|
249
|
+
/**
|
|
250
|
+
* Origins/client ids this caller trusts a token to have been issued to,
|
|
251
|
+
* checked against the token's `azp` claim when present, falling back to a
|
|
252
|
+
* string `aud` otherwise.
|
|
253
|
+
*
|
|
254
|
+
* WHY `azp` FIRST: this identity server always sets `azp` to the
|
|
255
|
+
* requesting client's own id, even for an access token whose `aud` was
|
|
256
|
+
* overridden to an RFC 8707 resource indicator -- `azp` keeps naming who
|
|
257
|
+
* the token was issued to regardless of what `aud` also claims to be valid
|
|
258
|
+
* at. `aud` is only a safe fallback when it is a single string naming one
|
|
259
|
+
* party; this package does not attempt the allowlist check against a
|
|
260
|
+
* multi-valued `aud`.
|
|
261
|
+
*
|
|
262
|
+
* WHY THIS CHECK EXISTS AT ALL: every TDACorp product shares the
|
|
263
|
+
* `tdacorp.in` registrable domain, and this server's `aud`/`azp` values
|
|
264
|
+
* are opaque client ids, not origins that a browser's same-origin policy
|
|
265
|
+
* already separates. A token minted for one product is not otherwise
|
|
266
|
+
* prevented from being replayed against another product that shares this
|
|
267
|
+
* issuer -- both the plain `audience` check above and this allowlist are
|
|
268
|
+
* needed, and they check different things: one asks whether the token is
|
|
269
|
+
* FOR this resource server, the other asks whether it was issued THROUGH
|
|
270
|
+
* a client this caller has chosen to trust.
|
|
271
|
+
*/
|
|
272
|
+
authorizedParties?: string[];
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* The verified claims of an OIDC id_token, as returned by `verifyIdToken`.
|
|
276
|
+
*
|
|
277
|
+
* Only the claims every id_token carries per OpenID Connect Core 1.0 §2 are
|
|
278
|
+
* typed here (`sub`, `iss`, `aud`, `exp`, `iat`); anything else the identity
|
|
279
|
+
* server includes (e.g. `email`, `name`) still comes through on the object,
|
|
280
|
+
* covered by the index signature, just not individually typed.
|
|
281
|
+
*/
|
|
282
|
+
interface IdTokenClaims extends Record<string, unknown> {
|
|
283
|
+
sub: string;
|
|
284
|
+
iss: string;
|
|
285
|
+
aud: string | string[];
|
|
286
|
+
exp: number;
|
|
287
|
+
iat: number;
|
|
288
|
+
}
|
|
289
|
+
/** Verifies an OIDC id_token's signature, issuer, audience and expiry (with
|
|
290
|
+
* the clock-skew tolerance documented on `CLOCK_SKEW_TOLERANCE_SECONDS`).
|
|
291
|
+
* Never throws for an expected verification failure -- expired, bad
|
|
292
|
+
* signature, wrong issuer/audience -- returning `{ success: false, errors
|
|
293
|
+
* }` instead. Throws only for missing required config (`options.issuer` /
|
|
294
|
+
* `options.audience`), which is a programming error, not a property of the
|
|
295
|
+
* token. */
|
|
296
|
+
declare function verifyIdToken(idToken: string, options: VerifyTokenOptions): Promise<VerificationResult<IdTokenClaims>>;
|
|
297
|
+
/** Verifies an access token the same way `verifyIdToken` does, then shapes
|
|
298
|
+
* the result as a `VerifiedAccessToken` (see that type's own docblock for
|
|
299
|
+
* why user and machine tokens are a discriminated union rather than one
|
|
300
|
+
* flat claims bag) with the `roles` claim, if present, typed as
|
|
301
|
+
* `RolesClaim` from `@tdacorp/identity-authz` rather than left as an
|
|
302
|
+
* unparsed field the caller has to know to reach for. Same throw/return
|
|
303
|
+
* contract as `verifyIdToken`. */
|
|
304
|
+
declare function verifyAccessToken(accessToken: string, options: VerifyTokenOptions): Promise<VerificationResult<VerifiedAccessToken>>;
|
|
305
|
+
|
|
306
|
+
export { type AuthorizationCodeExchangeOptions as A, CLOCK_SKEW_TOLERANCE_SECONDS as C, type IdTokenClaims as I, type RefreshTokensOptions as R, type TokenRefreshOutcome as T, type VerificationResult as V, type ClientCredentialsGrantOptions as a, TokenRequestError as b, type TokenSet as c, type TokenVerificationError as d, type TokenVerificationErrorCode as e, type VerifiedAccessToken as f, type VerifyTokenOptions as g, clientCredentialsGrant as h, exchangeAuthorizationCode as i, verifyIdToken as j, refreshTokens as r, verifyAccessToken as v };
|