@ultimat3/auth 1.0.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 +168 -0
- package/package.json +37 -0
- package/src/adapter.ts +157 -0
- package/src/api-keys.ts +141 -0
- package/src/auth.ts +233 -0
- package/src/builtin-adapter.ts +286 -0
- package/src/errors.ts +229 -0
- package/src/guards.ts +43 -0
- package/src/id-token-fixture.ts +16 -0
- package/src/id-token.ts +161 -0
- package/src/index.ts +236 -0
- package/src/memory-adapter.ts +170 -0
- package/src/mfa.ts +209 -0
- package/src/oauth-cookie.ts +209 -0
- package/src/oauth-exchange.ts +244 -0
- package/src/oauth-login.ts +193 -0
- package/src/oauth-profile.ts +213 -0
- package/src/oauth.ts +168 -0
- package/src/password.ts +136 -0
- package/src/policy-bridge.ts +105 -0
- package/src/rate-limit.ts +104 -0
- package/src/session.ts +253 -0
- package/src/tables.ts +86 -0
- package/src/tokens.ts +51 -0
- package/src/verify.ts +127 -0
package/src/errors.ts
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
// Single responsibility: this package's stable X_ codes and the factories that build them.
|
|
2
|
+
// Auth is the one layer where a precise error message is itself a vulnerability, so the
|
|
3
|
+
// factories here are deliberately coarse — `rate-limit.ts` owns the single login failure
|
|
4
|
+
// every credential path must throw, and nothing else describes *why* a credential failed.
|
|
5
|
+
|
|
6
|
+
import { registerErrorCodes, UltimateError } from '@ultimat3/core';
|
|
7
|
+
|
|
8
|
+
/** Codes this package declares and owns. `X_UNAUTHENTICATED` is auth's; http only borrows it. */
|
|
9
|
+
export const AUTH_OWNED_ERROR_CODES = [
|
|
10
|
+
'X_UNAUTHENTICATED',
|
|
11
|
+
'X_SESSION_EXPIRED',
|
|
12
|
+
'X_MFA_REQUIRED',
|
|
13
|
+
'X_OAUTH_STATE_INVALID',
|
|
14
|
+
'X_OAUTH_EXCHANGE_FAILED',
|
|
15
|
+
'X_OAUTH_TOKEN_INVALID',
|
|
16
|
+
'X_PASSWORD_WEAK',
|
|
17
|
+
'X_ACCOUNT_LOCKED',
|
|
18
|
+
'X_API_KEY_INVALID',
|
|
19
|
+
'X_AUTH_WRITE_FAILED',
|
|
20
|
+
] as const;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Codes another package owns that auth only throws: `X_FORBIDDEN` is `@ultimat3/policy`'s and
|
|
24
|
+
* `X_NOT_IMPLEMENTED` is `@ultimat3/core`'s. No titles here on purpose — a second copy of a title
|
|
25
|
+
* is a title that drifts, and registering one of these would be `X_ERROR_CODE_DUPLICATE`.
|
|
26
|
+
*/
|
|
27
|
+
export const AUTH_BORROWED_ERROR_CODES = ['X_FORBIDDEN', 'X_NOT_IMPLEMENTED'] as const;
|
|
28
|
+
|
|
29
|
+
/** Every code auth can throw: the ones it owns plus the ones it borrows. */
|
|
30
|
+
export const AUTH_ERROR_CODES = [...AUTH_OWNED_ERROR_CODES, ...AUTH_BORROWED_ERROR_CODES] as const;
|
|
31
|
+
|
|
32
|
+
export type AuthOwnedErrorCode = (typeof AUTH_OWNED_ERROR_CODES)[number];
|
|
33
|
+
export type AuthErrorCode = (typeof AUTH_ERROR_CODES)[number];
|
|
34
|
+
|
|
35
|
+
export const AUTH_ERROR_TITLES: Readonly<Record<AuthOwnedErrorCode, string>> = {
|
|
36
|
+
X_UNAUTHENTICATED: 'no authenticated actor for this request',
|
|
37
|
+
X_SESSION_EXPIRED: 'session passed its idle or absolute expiry',
|
|
38
|
+
X_MFA_REQUIRED: 'a second factor is required before this session is usable',
|
|
39
|
+
X_OAUTH_STATE_INVALID: 'oauth state, nonce or pkce verifier did not match',
|
|
40
|
+
X_OAUTH_EXCHANGE_FAILED: 'the oauth provider refused the exchange or returned no usable identity',
|
|
41
|
+
X_OAUTH_TOKEN_INVALID: 'id token failed its issuer, audience or expiry check',
|
|
42
|
+
X_PASSWORD_WEAK: 'password does not meet the configured policy',
|
|
43
|
+
X_ACCOUNT_LOCKED: 'too many failed attempts; this key is locked out',
|
|
44
|
+
X_API_KEY_INVALID: 'api key is unknown, revoked, expired or wrong',
|
|
45
|
+
X_AUTH_WRITE_FAILED: 'an adapter write returned no row, so it cannot be confirmed',
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// Registered unconditionally, in one call: a second package claiming a code auth owns has to fail
|
|
49
|
+
// loudly as X_ERROR_CODE_DUPLICATE at import. A presence guard would turn that collision into
|
|
50
|
+
// whichever package loaded first deciding what the code means.
|
|
51
|
+
registerErrorCodes(
|
|
52
|
+
Object.fromEntries(Object.entries(AUTH_ERROR_TITLES).map(([code, title]) => [code, { title }])),
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
/** Every code an `AuthError` may carry — the owned ones and the two borrowed ones alike. */
|
|
56
|
+
export type AuthThrowCode = AuthErrorCode;
|
|
57
|
+
|
|
58
|
+
export class AuthError extends UltimateError {
|
|
59
|
+
override readonly name = 'AuthError';
|
|
60
|
+
|
|
61
|
+
constructor(init: {
|
|
62
|
+
code: AuthThrowCode;
|
|
63
|
+
cause: string;
|
|
64
|
+
fix: string;
|
|
65
|
+
meta?: Readonly<Record<string, unknown>> | undefined;
|
|
66
|
+
}) {
|
|
67
|
+
super({
|
|
68
|
+
code: init.code,
|
|
69
|
+
cause: init.cause,
|
|
70
|
+
fix: init.fix,
|
|
71
|
+
docs: `https://ultimate.dev/errors/${init.code}`,
|
|
72
|
+
meta: init.meta,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export const unauthenticated = (surface: string): AuthError =>
|
|
78
|
+
new AuthError({
|
|
79
|
+
code: 'X_UNAUTHENTICATED',
|
|
80
|
+
cause: `${surface} needs an actor but ctx.actor is anonymous`,
|
|
81
|
+
fix: 'x auth whoami --json # confirm the request carries the __Host-x_session cookie',
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
export const forbidden = (surface: string, reason: string): AuthError =>
|
|
85
|
+
new AuthError({
|
|
86
|
+
code: 'X_FORBIDDEN',
|
|
87
|
+
cause: `${surface} denied: ${reason}`,
|
|
88
|
+
fix: 'x policy explain --json # shows which grant the actor is missing',
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
/** `kind` names which clock ran out — the two expiries are evaluated independently. */
|
|
92
|
+
export const sessionExpired = (kind: 'absolute' | 'idle', sessionId: string): AuthError =>
|
|
93
|
+
new AuthError({
|
|
94
|
+
code: 'X_SESSION_EXPIRED',
|
|
95
|
+
cause: `session ${sessionId} passed its ${kind} expiry`,
|
|
96
|
+
fix: `sign in again, or raise session.${kind}TtlMs in defineAuth({ session })`,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
export const sessionUnknown = (): AuthError =>
|
|
100
|
+
new AuthError({
|
|
101
|
+
code: 'X_UNAUTHENTICATED',
|
|
102
|
+
cause: 'the session cookie does not match any live session',
|
|
103
|
+
fix: 'x auth sessions list --json # then sign in again to mint a fresh session',
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
export const mfaRequired = (userId: string): AuthError =>
|
|
107
|
+
new AuthError({
|
|
108
|
+
code: 'X_MFA_REQUIRED',
|
|
109
|
+
cause: `user ${userId} has TOTP enrolled and this session has not satisfied it`,
|
|
110
|
+
fix: 'POST /auth/mfa/verify { code } with the 6-digit code, then retry',
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
export const oauthStateInvalid = (provider: string, part: string): AuthError =>
|
|
114
|
+
new AuthError({
|
|
115
|
+
code: 'X_OAUTH_STATE_INVALID',
|
|
116
|
+
cause: `${provider} callback rejected: ${part}`,
|
|
117
|
+
fix: `restart the flow at GET /auth/oauth/${provider} — a callback URL is single-use`,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
export interface OAuthExchangeFailure {
|
|
121
|
+
readonly provider: string;
|
|
122
|
+
/** Which leg of the server-to-server conversation failed. */
|
|
123
|
+
readonly stage: 'token' | 'userinfo';
|
|
124
|
+
readonly detail: string;
|
|
125
|
+
readonly status?: number | undefined;
|
|
126
|
+
readonly fix: string;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Deliberately specific, unlike every credential error above it. This one describes a
|
|
131
|
+
* conversation between two servers — naming the stage, the provider and its own status
|
|
132
|
+
* discloses nothing about any user, and is the difference between a fixable misconfiguration
|
|
133
|
+
* and a shrug.
|
|
134
|
+
*/
|
|
135
|
+
export const oauthExchangeFailed = (failure: OAuthExchangeFailure): AuthError =>
|
|
136
|
+
new AuthError({
|
|
137
|
+
code: 'X_OAUTH_EXCHANGE_FAILED',
|
|
138
|
+
cause:
|
|
139
|
+
`${failure.provider} ${failure.stage} request failed` +
|
|
140
|
+
`${failure.status === undefined ? '' : ` with HTTP ${failure.status}`}: ${failure.detail}`,
|
|
141
|
+
fix: failure.fix,
|
|
142
|
+
meta: {
|
|
143
|
+
provider: failure.provider,
|
|
144
|
+
stage: failure.stage,
|
|
145
|
+
...(failure.status === undefined ? {} : { status: failure.status }),
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The address is proven to the provider, and an account that never proved it already holds it.
|
|
151
|
+
* Naming that is not account enumeration — this caller just demonstrated they own the address —
|
|
152
|
+
* and staying silent would leave them with a login that fails forever and no way out.
|
|
153
|
+
*
|
|
154
|
+
* The address itself rides in `meta`, never in `cause`: a log pipeline can redact a field by
|
|
155
|
+
* key, and cannot redact an address that was already interpolated into a sentence.
|
|
156
|
+
*/
|
|
157
|
+
export const oauthAccountNotLinked = (provider: string, email: string): AuthError =>
|
|
158
|
+
new AuthError({
|
|
159
|
+
code: 'X_UNAUTHENTICATED',
|
|
160
|
+
cause: `an account holds this ${provider} address but never verified it, so ${provider} may not claim it`,
|
|
161
|
+
fix: `sign in with that account's password and confirm the email-verify link, then retry ${provider}`,
|
|
162
|
+
meta: { provider, email },
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* `CreateUserInput` carries no `emailVerifiedAt`, so a provider-verified address takes a second
|
|
167
|
+
* write. Falling back to the unstamped row would mint a session for a user every later login
|
|
168
|
+
* reads as unverified — the exact state `resolveUser` refuses to link a provider to — so the
|
|
169
|
+
* flow fails closed on an adapter that loses the stamp instead of half-succeeding.
|
|
170
|
+
*/
|
|
171
|
+
export const emailVerifiedNotStored = (provider: string, userId: string): AuthError =>
|
|
172
|
+
new AuthError({
|
|
173
|
+
code: 'X_NOT_IMPLEMENTED',
|
|
174
|
+
cause: `the adapter returned no row for new user ${userId}, so the ${provider}-verified address was never stamped verified`,
|
|
175
|
+
fix: 'return the updated row from AuthAdapter.updateUser — MemoryAdapter.updateUser is the reference implementation',
|
|
176
|
+
meta: { provider, userId },
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
/** The token arrived, and is not one this handshake can trust: wrong `iss`, `aud`, or expired. */
|
|
180
|
+
export const oauthTokenInvalid = (provider: string, reason: string, fix: string): AuthError =>
|
|
181
|
+
new AuthError({
|
|
182
|
+
code: 'X_OAUTH_TOKEN_INVALID',
|
|
183
|
+
cause: `${provider} id token rejected: ${reason}`,
|
|
184
|
+
fix,
|
|
185
|
+
meta: { provider },
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
export const passwordWeak = (reasons: readonly string[]): AuthError =>
|
|
189
|
+
new AuthError({
|
|
190
|
+
code: 'X_PASSWORD_WEAK',
|
|
191
|
+
cause: `password rejected: ${reasons.join('; ')}`,
|
|
192
|
+
fix: 'choose a longer, uncommon password — or relax defineAuth({ password: { minLength } })',
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
export const accountLocked = (key: string, retryAfterSeconds: number): AuthError =>
|
|
196
|
+
new AuthError({
|
|
197
|
+
code: 'X_ACCOUNT_LOCKED',
|
|
198
|
+
cause: `${key} is locked out for another ${retryAfterSeconds}s after repeated failures`,
|
|
199
|
+
fix: `wait ${retryAfterSeconds}s, or run: x auth unlock ${key}`,
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
/** One shape for every api-key rejection: unknown, revoked, expired and wrong all look alike. */
|
|
203
|
+
export const apiKeyInvalid = (): AuthError =>
|
|
204
|
+
new AuthError({
|
|
205
|
+
code: 'X_API_KEY_INVALID',
|
|
206
|
+
cause: 'the presented api key is unknown, revoked, expired or does not match its hash',
|
|
207
|
+
fix: 'x auth keys list --json # then: x auth keys issue --scopes "<scope>"',
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* A write whose `returning *` came back empty wrote nothing the caller may trust. Synthesising a
|
|
212
|
+
* row from `{}` is what would let `register()` hand back a user with no id and no email — a
|
|
213
|
+
* registration that reads as successful and authenticates nobody — so the adapter fails closed.
|
|
214
|
+
*/
|
|
215
|
+
export const authWriteFailed = (operation: string, table: string): AuthError =>
|
|
216
|
+
new AuthError({
|
|
217
|
+
code: 'X_AUTH_WRITE_FAILED',
|
|
218
|
+
cause: `${operation} returned no row from ${table}, so the write cannot be confirmed`,
|
|
219
|
+
fix: `x db migrate # then: x db query "select 1 from ${table} limit 1" --json`,
|
|
220
|
+
meta: { operation, table },
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
/** For a custom `AuthAdapter` that implements part of the seam. Nothing shipped throws it. */
|
|
224
|
+
export const authNotImplemented = (feature: string, fix: string): AuthError =>
|
|
225
|
+
new AuthError({
|
|
226
|
+
code: 'X_NOT_IMPLEMENTED',
|
|
227
|
+
cause: `${feature} is not implemented by the built-in driver`,
|
|
228
|
+
fix,
|
|
229
|
+
});
|
package/src/guards.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Single responsibility: the assertions the http pipeline's auth stage runs. They read the
|
|
2
|
+
// ambient actor from core's context and assert on it — they never evaluate a policy, never
|
|
3
|
+
// load a row and never look at a session. `@ultimat3/policy` is the only authz evaluator;
|
|
4
|
+
// duplicating even a little of it here would be the second authz system the framework forbids.
|
|
5
|
+
|
|
6
|
+
import type { Actor } from '@ultimat3/core';
|
|
7
|
+
import { hasRole, hasScope, isAnonymous, useContext } from '@ultimat3/core';
|
|
8
|
+
import { forbidden, unauthenticated } from './errors';
|
|
9
|
+
|
|
10
|
+
const DEFAULT_SURFACE = 'this request';
|
|
11
|
+
|
|
12
|
+
/** Throws `X_UNAUTHENTICATED` when the ambient actor is anonymous. */
|
|
13
|
+
export function requireActor(surface: string = DEFAULT_SURFACE): Actor {
|
|
14
|
+
const { actor } = useContext();
|
|
15
|
+
if (isAnonymous(actor)) throw unauthenticated(surface);
|
|
16
|
+
return actor;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A coarse role gate for routes that are role-shaped rather than permission-shaped (an admin
|
|
21
|
+
* area). Anything finer belongs in a policy — `can('post:publish')`, evaluated by policy.
|
|
22
|
+
*/
|
|
23
|
+
export function requireRole(role: string, surface: string = DEFAULT_SURFACE): Actor {
|
|
24
|
+
const actor = requireActor(surface);
|
|
25
|
+
if (!hasRole(actor, role)) throw forbidden(surface, `actor lacks role "${role}"`);
|
|
26
|
+
return actor;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The api-key path: an agent's scopes are exactly its key's scopes, so a scope check is a
|
|
31
|
+
* credential check, not an authorization decision.
|
|
32
|
+
*/
|
|
33
|
+
export function requireScope(scope: string, surface: string = DEFAULT_SURFACE): Actor {
|
|
34
|
+
const actor = requireActor(surface);
|
|
35
|
+
if (!hasScope(actor, scope)) throw forbidden(surface, `actor lacks scope "${scope}"`);
|
|
36
|
+
return actor;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Non-throwing form for a route that renders differently when signed out. */
|
|
40
|
+
export function currentActor(): Actor | null {
|
|
41
|
+
const { actor } = useContext();
|
|
42
|
+
return isAnonymous(actor) ? null : actor;
|
|
43
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Single responsibility: the one way this package's tests mint an id token. Three OAuth test
|
|
2
|
+
// files each needs a base64url-encoded JWT, and three private copies of the encoder is three
|
|
3
|
+
// chances for one to drift from what `decodeIdToken` actually parses. Not part of the public
|
|
4
|
+
// API — `index.ts` deliberately does not re-export it.
|
|
5
|
+
|
|
6
|
+
import { base64Url } from './tokens';
|
|
7
|
+
|
|
8
|
+
/** `base64Url` takes bytes because every real secret is bytes; a JWT segment is text. */
|
|
9
|
+
export const base64UrlText = (value: string): string => base64Url(new TextEncoder().encode(value));
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Header, payload, and a signature that is not one. Signatures are never checked here — the
|
|
13
|
+
* token is only ever read straight off the token endpoint — so a fixture needs no signer.
|
|
14
|
+
*/
|
|
15
|
+
export const unsignedJwt = (claims: Readonly<Record<string, unknown>>): string =>
|
|
16
|
+
`${base64UrlText('{"alg":"RS256"}')}.${base64UrlText(JSON.stringify(claims))}.signature`;
|
package/src/id-token.ts
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// Single responsibility: turning a provider's id token into claims this handshake is allowed to
|
|
2
|
+
// believe. Signature verification is deliberately absent: this token is read only where it was
|
|
3
|
+
// fetched over TLS directly from the provider's token endpoint, which is the one case OIDC Core
|
|
4
|
+
// 3.1.3.7 exempts — a token that reaches the browser instead must never be parsed here.
|
|
5
|
+
|
|
6
|
+
import type { Clock } from '@ultimat3/core';
|
|
7
|
+
import { oauthStateInvalid, oauthTokenInvalid } from './errors';
|
|
8
|
+
import { OAUTH_PROVIDERS, type OAuthProvider, type OAuthProviderId } from './oauth';
|
|
9
|
+
import { timingSafeEqual } from './tokens';
|
|
10
|
+
|
|
11
|
+
/** The subset of OIDC claims this package acts on. Provider-specific extras are ignored. */
|
|
12
|
+
export interface IdTokenClaims {
|
|
13
|
+
readonly iss: string;
|
|
14
|
+
readonly aud: string | readonly string[];
|
|
15
|
+
readonly sub: string;
|
|
16
|
+
readonly exp: number;
|
|
17
|
+
readonly iat?: number | undefined;
|
|
18
|
+
readonly nonce?: string | undefined;
|
|
19
|
+
readonly email?: string | undefined;
|
|
20
|
+
/** Google sends a boolean, Apple a `"true"` string. Both mean the same thing. */
|
|
21
|
+
readonly email_verified?: boolean | string | undefined;
|
|
22
|
+
readonly name?: string | undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Two servers rarely agree on the second. Anything wider hides a genuinely expired token. */
|
|
26
|
+
export const ID_TOKEN_CLOCK_SKEW_MS = 60_000;
|
|
27
|
+
|
|
28
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
29
|
+
typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
30
|
+
|
|
31
|
+
const stringOrUndefined = (value: unknown): string | undefined =>
|
|
32
|
+
typeof value === 'string' ? value : undefined;
|
|
33
|
+
|
|
34
|
+
function decodeSegment(provider: string, segment: string, fix: string): Record<string, unknown> {
|
|
35
|
+
const padded = segment
|
|
36
|
+
.replace(/-/g, '+')
|
|
37
|
+
.replace(/_/g, '/')
|
|
38
|
+
.padEnd(Math.ceil(segment.length / 4) * 4, '=');
|
|
39
|
+
let parsed: unknown;
|
|
40
|
+
try {
|
|
41
|
+
const binary = atob(padded);
|
|
42
|
+
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
|
|
43
|
+
parsed = JSON.parse(new TextDecoder().decode(bytes));
|
|
44
|
+
} catch {
|
|
45
|
+
throw oauthTokenInvalid(provider, 'the payload segment is not base64url-encoded JSON', fix);
|
|
46
|
+
}
|
|
47
|
+
if (!isRecord(parsed)) throw oauthTokenInvalid(provider, 'the payload is not an object', fix);
|
|
48
|
+
return parsed;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Structure only — no issuer, audience, expiry or nonce check. `verifyIdToken` is the entry
|
|
53
|
+
* point every flow uses; this one exists because the two halves are worth reading apart.
|
|
54
|
+
*/
|
|
55
|
+
export function decodeIdToken(provider: OAuthProviderId, idToken: string): IdTokenClaims {
|
|
56
|
+
const fix = `check that ${OAUTH_PROVIDERS[provider].clientIdEnv} names an app whose id token is a JWT`;
|
|
57
|
+
const segments = idToken.split('.');
|
|
58
|
+
if (segments.length !== 3 || segments[1] === undefined || segments[1] === '') {
|
|
59
|
+
throw oauthTokenInvalid(provider, 'the token is not a three-segment JWT', fix);
|
|
60
|
+
}
|
|
61
|
+
const payload = decodeSegment(provider, segments[1], fix);
|
|
62
|
+
const iss = stringOrUndefined(payload['iss']);
|
|
63
|
+
const sub = stringOrUndefined(payload['sub']);
|
|
64
|
+
const aud = payload['aud'];
|
|
65
|
+
const exp = payload['exp'];
|
|
66
|
+
if (iss === undefined || sub === undefined) {
|
|
67
|
+
throw oauthTokenInvalid(provider, 'the payload has no iss or sub claim', fix);
|
|
68
|
+
}
|
|
69
|
+
if (typeof exp !== 'number') {
|
|
70
|
+
throw oauthTokenInvalid(provider, 'the payload has no numeric exp claim', fix);
|
|
71
|
+
}
|
|
72
|
+
// The filter can empty a non-empty array (`aud: [1, 2]`), and an empty audience addresses
|
|
73
|
+
// nobody — `verifyIdToken` would then check the client id against nothing and pass.
|
|
74
|
+
const audience = Array.isArray(aud) ? aud.filter((one) => typeof one === 'string') : aud;
|
|
75
|
+
if (typeof audience !== 'string' && !(Array.isArray(audience) && audience.length > 0)) {
|
|
76
|
+
throw oauthTokenInvalid(provider, 'the payload has no aud claim naming a string audience', fix);
|
|
77
|
+
}
|
|
78
|
+
// `exactOptionalPropertyTypes`: an absent claim must be absent, not present-and-undefined.
|
|
79
|
+
const iat = payload['iat'];
|
|
80
|
+
const verified = payload['email_verified'];
|
|
81
|
+
const nonce = stringOrUndefined(payload['nonce']);
|
|
82
|
+
const email = stringOrUndefined(payload['email']);
|
|
83
|
+
const name = stringOrUndefined(payload['name']);
|
|
84
|
+
return {
|
|
85
|
+
iss,
|
|
86
|
+
aud: audience,
|
|
87
|
+
sub,
|
|
88
|
+
exp,
|
|
89
|
+
...(typeof iat === 'number' ? { iat } : {}),
|
|
90
|
+
...(nonce === undefined ? {} : { nonce }),
|
|
91
|
+
...(email === undefined ? {} : { email }),
|
|
92
|
+
...(typeof verified === 'boolean' || typeof verified === 'string'
|
|
93
|
+
? { email_verified: verified }
|
|
94
|
+
: {}),
|
|
95
|
+
...(name === undefined ? {} : { name }),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* `"true"` and `true` both count; anything else — absent, `"false"`, a number — does not. Lives
|
|
101
|
+
* here rather than at each call site because userinfo spells the same flag the same two ways, and
|
|
102
|
+
* two copies of the rule is two places for one of them to start rounding a login up to verified.
|
|
103
|
+
*/
|
|
104
|
+
export const isVerifiedFlag = (value: unknown): boolean => value === true || value === 'true';
|
|
105
|
+
|
|
106
|
+
export function idTokenEmailVerified(claims: IdTokenClaims): boolean {
|
|
107
|
+
return isVerifiedFlag(claims.email_verified);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface VerifyIdTokenInput {
|
|
111
|
+
readonly provider: OAuthProviderId;
|
|
112
|
+
readonly idToken: string;
|
|
113
|
+
/** The client id the authorize URL was built with. The token must be addressed to it. */
|
|
114
|
+
readonly clientId: string;
|
|
115
|
+
/** `OAuthHandshake.nonce`. Checked whenever the provider was asked for one. */
|
|
116
|
+
readonly nonce: string;
|
|
117
|
+
readonly clock: Clock;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Issuer, audience, expiry and nonce, in that order. A nonce mismatch is
|
|
122
|
+
* `X_OAUTH_STATE_INVALID` rather than a token error on purpose: it is the same class of event
|
|
123
|
+
* as a forged `state` — a token minted for another browser being replayed into this one.
|
|
124
|
+
*/
|
|
125
|
+
export function verifyIdToken(input: VerifyIdTokenInput): IdTokenClaims {
|
|
126
|
+
// Widened on purpose: `issuers` is a literal `readonly []` for a provider that issues no id
|
|
127
|
+
// token, and `[].includes(string)` does not typecheck against `never`.
|
|
128
|
+
const provider: OAuthProvider = OAUTH_PROVIDERS[input.provider];
|
|
129
|
+
const claims = decodeIdToken(input.provider, input.idToken);
|
|
130
|
+
|
|
131
|
+
if (!provider.issuers.includes(claims.iss)) {
|
|
132
|
+
throw oauthTokenInvalid(
|
|
133
|
+
provider.id,
|
|
134
|
+
`iss was "${claims.iss}", expected one of ${provider.issuers.join(', ')}`,
|
|
135
|
+
`confirm the token came from ${provider.tokenUrl} and not from a proxy that re-signs it`,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const audience = typeof claims.aud === 'string' ? [claims.aud] : claims.aud;
|
|
140
|
+
if (!audience.includes(input.clientId)) {
|
|
141
|
+
throw oauthTokenInvalid(
|
|
142
|
+
provider.id,
|
|
143
|
+
'aud does not include the client id this handshake was started with',
|
|
144
|
+
`set ${provider.clientIdEnv} to the same client id beginOAuth() was called with`,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (claims.exp * 1000 + ID_TOKEN_CLOCK_SKEW_MS <= input.clock.now().getTime()) {
|
|
149
|
+
throw oauthTokenInvalid(
|
|
150
|
+
provider.id,
|
|
151
|
+
'the token is already expired',
|
|
152
|
+
"sync this host's clock (`timedatectl status`), then restart the flow",
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (provider.usesNonce && !timingSafeEqual(input.nonce, claims.nonce ?? '')) {
|
|
157
|
+
throw oauthStateInvalid(provider.id, 'the id token nonce did not match the stored handshake');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return claims;
|
|
161
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// Single responsibility: the public API of @ultimat3/auth. Explicit named exports only — this
|
|
2
|
+
// list is what the http pipeline, the MCP surface and generated apps are allowed to depend on.
|
|
3
|
+
|
|
4
|
+
export type {
|
|
5
|
+
AccountStore,
|
|
6
|
+
ApiKeyStore,
|
|
7
|
+
AuthAccount,
|
|
8
|
+
AuthAdapter,
|
|
9
|
+
AuthApiKeyRecord,
|
|
10
|
+
AuthSession,
|
|
11
|
+
AuthUser,
|
|
12
|
+
AuthVerification,
|
|
13
|
+
CreateUserInput,
|
|
14
|
+
SessionPatch,
|
|
15
|
+
SessionStore,
|
|
16
|
+
UserPatch,
|
|
17
|
+
UserStore,
|
|
18
|
+
VerificationStore,
|
|
19
|
+
} from './adapter';
|
|
20
|
+
export type { ApiKeySummary, IssueApiKeyInput, IssuedApiKey, ParsedApiKey } from './api-keys';
|
|
21
|
+
export {
|
|
22
|
+
API_KEY_NAMESPACE,
|
|
23
|
+
API_KEY_PREFIX_SEGMENTS,
|
|
24
|
+
apiKeyActor,
|
|
25
|
+
apiKeyPrefix,
|
|
26
|
+
describeApiKey,
|
|
27
|
+
issueApiKey,
|
|
28
|
+
parseApiKey,
|
|
29
|
+
revokeApiKey,
|
|
30
|
+
verifyApiKey,
|
|
31
|
+
} from './api-keys';
|
|
32
|
+
export type {
|
|
33
|
+
Auth,
|
|
34
|
+
AuthConfigInput,
|
|
35
|
+
AuthMfaPolicy,
|
|
36
|
+
LoginInput,
|
|
37
|
+
LoginResult,
|
|
38
|
+
RegisterInput,
|
|
39
|
+
} from './auth';
|
|
40
|
+
export {
|
|
41
|
+
AccountSchema,
|
|
42
|
+
authenticate,
|
|
43
|
+
defineAuth,
|
|
44
|
+
login,
|
|
45
|
+
logout,
|
|
46
|
+
register,
|
|
47
|
+
SessionSchema,
|
|
48
|
+
UserSchema,
|
|
49
|
+
VerificationSchema,
|
|
50
|
+
} from './auth';
|
|
51
|
+
|
|
52
|
+
export { BuiltinAdapter } from './builtin-adapter';
|
|
53
|
+
export type { AuthErrorCode, AuthThrowCode, OAuthExchangeFailure } from './errors';
|
|
54
|
+
export {
|
|
55
|
+
AUTH_BORROWED_ERROR_CODES,
|
|
56
|
+
AUTH_ERROR_CODES,
|
|
57
|
+
AUTH_ERROR_TITLES,
|
|
58
|
+
AuthError,
|
|
59
|
+
accountLocked,
|
|
60
|
+
apiKeyInvalid,
|
|
61
|
+
authNotImplemented,
|
|
62
|
+
authWriteFailed,
|
|
63
|
+
emailVerifiedNotStored,
|
|
64
|
+
forbidden,
|
|
65
|
+
mfaRequired,
|
|
66
|
+
oauthAccountNotLinked,
|
|
67
|
+
oauthExchangeFailed,
|
|
68
|
+
oauthStateInvalid,
|
|
69
|
+
oauthTokenInvalid,
|
|
70
|
+
passwordWeak,
|
|
71
|
+
sessionExpired,
|
|
72
|
+
sessionUnknown,
|
|
73
|
+
unauthenticated,
|
|
74
|
+
} from './errors';
|
|
75
|
+
|
|
76
|
+
export { currentActor, requireActor, requireRole, requireScope } from './guards';
|
|
77
|
+
export type { IdTokenClaims, VerifyIdTokenInput } from './id-token';
|
|
78
|
+
export {
|
|
79
|
+
decodeIdToken,
|
|
80
|
+
ID_TOKEN_CLOCK_SKEW_MS,
|
|
81
|
+
idTokenEmailVerified,
|
|
82
|
+
verifyIdToken,
|
|
83
|
+
} from './id-token';
|
|
84
|
+
|
|
85
|
+
export { MemoryAdapter } from './memory-adapter';
|
|
86
|
+
export type {
|
|
87
|
+
EnrolTotpInput,
|
|
88
|
+
RecoveryCodeSet,
|
|
89
|
+
TotpEnrolment,
|
|
90
|
+
TotpReplayGuard,
|
|
91
|
+
TotpVerification,
|
|
92
|
+
VerifyTotpInput,
|
|
93
|
+
} from './mfa';
|
|
94
|
+
export {
|
|
95
|
+
base32Decode,
|
|
96
|
+
base32Encode,
|
|
97
|
+
createTotpReplayGuard,
|
|
98
|
+
enrolTotp,
|
|
99
|
+
generateRecoveryCodes,
|
|
100
|
+
generateTotpSecret,
|
|
101
|
+
redeemRecoveryCode,
|
|
102
|
+
TOTP_DIGITS,
|
|
103
|
+
TOTP_DRIFT_STEPS,
|
|
104
|
+
TOTP_STEP_SECONDS,
|
|
105
|
+
totpCode,
|
|
106
|
+
totpStep,
|
|
107
|
+
verifyTotp,
|
|
108
|
+
} from './mfa';
|
|
109
|
+
export type {
|
|
110
|
+
BeginOAuthInput,
|
|
111
|
+
OAuthCallback,
|
|
112
|
+
OAuthHandshake,
|
|
113
|
+
OAuthProvider,
|
|
114
|
+
OAuthProviderId,
|
|
115
|
+
PkcePair,
|
|
116
|
+
} from './oauth';
|
|
117
|
+
export {
|
|
118
|
+
assertOAuthCallback,
|
|
119
|
+
beginOAuth,
|
|
120
|
+
createPkce,
|
|
121
|
+
OAUTH_PROVIDER_IDS,
|
|
122
|
+
OAUTH_PROVIDERS,
|
|
123
|
+
pkceChallenge,
|
|
124
|
+
} from './oauth';
|
|
125
|
+
export type { HandshakeCookieOptions, HandshakeSealOptions } from './oauth-cookie';
|
|
126
|
+
export {
|
|
127
|
+
clearHandshakeCookie,
|
|
128
|
+
DEFAULT_HANDSHAKE_TTL_MS,
|
|
129
|
+
handshakeCookie,
|
|
130
|
+
handshakeCookieName,
|
|
131
|
+
handshakeSecret,
|
|
132
|
+
OAUTH_HANDSHAKE_COOKIE_PREFIX,
|
|
133
|
+
openHandshake,
|
|
134
|
+
readHandshakeCookie,
|
|
135
|
+
sealHandshake,
|
|
136
|
+
} from './oauth-cookie';
|
|
137
|
+
export type {
|
|
138
|
+
OAuthClientCredentials,
|
|
139
|
+
OAuthExchangeOptions,
|
|
140
|
+
OAuthFetch,
|
|
141
|
+
OAuthTokens,
|
|
142
|
+
} from './oauth-exchange';
|
|
143
|
+
export { exchangeOAuthCode, oauthCredentials } from './oauth-exchange';
|
|
144
|
+
export type { CompleteOAuthLoginInput, OAuthSignInInput } from './oauth-login';
|
|
145
|
+
export { completeOAuthLogin, signInWithOAuth } from './oauth-login';
|
|
146
|
+
export type { OAuthProfile, OAuthProfileOptions } from './oauth-profile';
|
|
147
|
+
export { oauthProfile } from './oauth-profile';
|
|
148
|
+
export type {
|
|
149
|
+
PasswordParams,
|
|
150
|
+
PasswordPolicy,
|
|
151
|
+
PasswordVerification,
|
|
152
|
+
StrengthOptions,
|
|
153
|
+
VerifyPasswordInput,
|
|
154
|
+
} from './password';
|
|
155
|
+
export {
|
|
156
|
+
checkPasswordStrength,
|
|
157
|
+
DEFAULT_PASSWORD_PARAMS,
|
|
158
|
+
DEFAULT_PASSWORD_POLICY,
|
|
159
|
+
hashPassword,
|
|
160
|
+
needsRehash,
|
|
161
|
+
parseHashParams,
|
|
162
|
+
verifyPassword,
|
|
163
|
+
} from './password';
|
|
164
|
+
export type {
|
|
165
|
+
AuthIdentity,
|
|
166
|
+
PolicyActor,
|
|
167
|
+
PolicyActorFields,
|
|
168
|
+
ServiceIdentity,
|
|
169
|
+
} from './policy-bridge';
|
|
170
|
+
export {
|
|
171
|
+
actorFromApiKey,
|
|
172
|
+
actorFromService,
|
|
173
|
+
actorFromUser,
|
|
174
|
+
resolveActor,
|
|
175
|
+
} from './policy-bridge';
|
|
176
|
+
export type { AuthLimiter, AuthRateLimitPolicy } from './rate-limit';
|
|
177
|
+
export {
|
|
178
|
+
accountKey,
|
|
179
|
+
createAuthLimiter,
|
|
180
|
+
DEFAULT_AUTH_RATE_LIMIT,
|
|
181
|
+
ipKey,
|
|
182
|
+
loginFailed,
|
|
183
|
+
} from './rate-limit';
|
|
184
|
+
export type {
|
|
185
|
+
CookieJar,
|
|
186
|
+
CreateSessionInput,
|
|
187
|
+
IssuedSession,
|
|
188
|
+
RequestLike,
|
|
189
|
+
SessionCookieOptions,
|
|
190
|
+
SessionDevice,
|
|
191
|
+
SessionExpiry,
|
|
192
|
+
SessionPolicy,
|
|
193
|
+
SessionRuntime,
|
|
194
|
+
} from './session';
|
|
195
|
+
export {
|
|
196
|
+
clearSessionCookie,
|
|
197
|
+
createSession,
|
|
198
|
+
DEFAULT_SESSION_POLICY,
|
|
199
|
+
listDevices,
|
|
200
|
+
parseSessionToken,
|
|
201
|
+
readCookie,
|
|
202
|
+
readSessionCookie,
|
|
203
|
+
revokeOtherSessions,
|
|
204
|
+
revokeSession,
|
|
205
|
+
rotateSession,
|
|
206
|
+
sessionCookie,
|
|
207
|
+
sessionExpiry,
|
|
208
|
+
verifySession,
|
|
209
|
+
} from './session';
|
|
210
|
+
|
|
211
|
+
export {
|
|
212
|
+
AUTH_TABLE_NAMES,
|
|
213
|
+
AUTH_TABLES,
|
|
214
|
+
X_ACCOUNTS_TABLE,
|
|
215
|
+
X_API_KEYS_TABLE,
|
|
216
|
+
X_SESSIONS_TABLE,
|
|
217
|
+
X_USERS_TABLE,
|
|
218
|
+
X_VERIFICATIONS_TABLE,
|
|
219
|
+
} from './tables';
|
|
220
|
+
|
|
221
|
+
export { base64Url, matchesHash, randomToken, sha256Hex, timingSafeEqual } from './tokens';
|
|
222
|
+
export type {
|
|
223
|
+
ConsumeVerificationInput,
|
|
224
|
+
IssuedVerification,
|
|
225
|
+
IssueVerificationInput,
|
|
226
|
+
MailSender,
|
|
227
|
+
VerificationPurpose,
|
|
228
|
+
VerificationRuntime,
|
|
229
|
+
} from './verify';
|
|
230
|
+
export {
|
|
231
|
+
consumeVerification,
|
|
232
|
+
DEFAULT_VERIFICATION_TTL_MS,
|
|
233
|
+
issueVerification,
|
|
234
|
+
VERIFICATION_PURPOSES,
|
|
235
|
+
VERIFICATION_TEMPLATES,
|
|
236
|
+
} from './verify';
|