@ultimat3/auth 1.2.0 → 3.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/CLAUDE.md +275 -0
- package/README.md +438 -35
- package/package.json +5 -4
- package/src/adapter.ts +69 -4
- package/src/auth.ts +122 -18
- package/src/builtin-adapter.ts +83 -6
- package/src/directory.ts +77 -0
- package/src/email.ts +17 -0
- package/src/errors.ts +239 -14
- package/src/guards.ts +6 -26
- package/src/id-token.ts +48 -26
- package/src/index.ts +109 -13
- package/src/json.ts +33 -0
- package/src/jwks.ts +246 -0
- package/src/kdf-gate.ts +86 -0
- package/src/memory-adapter.ts +66 -4
- package/src/mfa.ts +104 -9
- package/src/oauth-builtins.ts +77 -0
- package/src/oauth-cookie.ts +4 -3
- package/src/oauth-discovery.ts +132 -0
- package/src/oauth-exchange.ts +40 -18
- package/src/oauth-login-fixture.ts +53 -0
- package/src/oauth-login.ts +111 -14
- package/src/oauth-paths.ts +20 -0
- package/src/oauth-profile.ts +9 -10
- package/src/oauth-registry.ts +65 -0
- package/src/oauth-route.ts +293 -0
- package/src/oauth.ts +31 -58
- package/src/password.ts +65 -13
- package/src/policy-bridge.ts +11 -5
- package/src/privileges.ts +74 -0
- package/src/rate-limit.ts +178 -15
- package/src/revocation.ts +100 -0
- package/src/session.ts +33 -4
- package/src/tables.ts +18 -2
- package/src/tokens.ts +26 -17
- package/src/verify.ts +12 -5
- package/src/workload.ts +131 -0
package/src/workload.ts
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// Single responsibility: verifying a workload's own JWT and turning it into a `ServiceIdentity`.
|
|
2
|
+
// Before this, `ServiceIdentity` was a plain struct with no non-test caller and nothing in the
|
|
3
|
+
// framework verified a service credential at all — no workload identity, no `client_credentials`,
|
|
4
|
+
// no token exchange — so two Ultimate services could only trust each other through a long-lived
|
|
5
|
+
// shared secret in an env var, with no rotation and no per-caller identity.
|
|
6
|
+
//
|
|
7
|
+
// One function covers the three shapes in practice, because they are all the same JWT: a
|
|
8
|
+
// Kubernetes projected service-account token, a SPIFFE JWT-SVID, and a cloud IMDS token. It is
|
|
9
|
+
// also the shape RFC 8693's `subject_token` takes, so a token-exchange endpoint reads through it.
|
|
10
|
+
//
|
|
11
|
+
// mTLS is deliberately out of scope: TLS termination is the mesh's job (axiom 7), and the
|
|
12
|
+
// framework's part is reading a trusted `x-forwarded-client-cert` through `@ultimat3/http`'s
|
|
13
|
+
// trusted-proxy seam — which is that package's to own, not this one's.
|
|
14
|
+
|
|
15
|
+
import type { Clock } from '@ultimat3/core';
|
|
16
|
+
import { AuthError } from './errors';
|
|
17
|
+
import { ID_TOKEN_CLOCK_SKEW_MS } from './id-token';
|
|
18
|
+
import { decodeJwtSegment } from './json';
|
|
19
|
+
import { type JwksKeySource, verifyJwtSignature } from './jwks';
|
|
20
|
+
import type { ServiceIdentity } from './policy-bridge';
|
|
21
|
+
|
|
22
|
+
/** The claims a workload token is read for. Everything else the issuer sends is ignored. */
|
|
23
|
+
export interface WorkloadClaims {
|
|
24
|
+
readonly iss: string;
|
|
25
|
+
readonly sub: string;
|
|
26
|
+
readonly aud: readonly string[];
|
|
27
|
+
readonly exp: number;
|
|
28
|
+
readonly nbf?: number | undefined;
|
|
29
|
+
readonly iat?: number | undefined;
|
|
30
|
+
/** `scope` (space-delimited, RFC 8693 / OAuth) or `scp` (an array). Absent means none. */
|
|
31
|
+
readonly scopes: readonly string[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface WorkloadToken {
|
|
35
|
+
readonly claims: WorkloadClaims;
|
|
36
|
+
/** Feed this to `actorFromService()` — the same funnel every other credential goes through. */
|
|
37
|
+
readonly identity: ServiceIdentity;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface VerifyWorkloadTokenInput {
|
|
41
|
+
readonly token: string;
|
|
42
|
+
/** Every issuer this caller may claim. A list, never a wildcard: an unpinned `iss` is no check. */
|
|
43
|
+
readonly issuers: readonly string[];
|
|
44
|
+
/** This service's own audience. A token addressed elsewhere is a token being replayed here. */
|
|
45
|
+
readonly audience: string;
|
|
46
|
+
/** Required. There is no trusted-channel exemption for a token that arrived in a header. */
|
|
47
|
+
readonly keys: JwksKeySource;
|
|
48
|
+
readonly clock: Clock;
|
|
49
|
+
/** Optional tenant, when the deployment gives a workload one. Never guessed from the token. */
|
|
50
|
+
readonly orgId?: string | null | undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const refused = (reason: string): AuthError =>
|
|
54
|
+
new AuthError({
|
|
55
|
+
code: 'X_UNAUTHENTICATED',
|
|
56
|
+
cause: `the presented workload token was refused: ${reason}`,
|
|
57
|
+
// A service credential names no human, so a precise cause enumerates nothing — and the
|
|
58
|
+
// reader of this line is an operator holding a misconfigured deployment, not an attacker.
|
|
59
|
+
fix: "confirm the workload token's issuer, audience and key set match verifyWorkloadToken({ issuers, audience, keys })",
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const readScopes = (payload: Record<string, unknown>): readonly string[] => {
|
|
63
|
+
const scope = payload['scope'];
|
|
64
|
+
if (typeof scope === 'string') return scope.split(' ').filter((one) => one !== '');
|
|
65
|
+
const scp = payload['scp'];
|
|
66
|
+
return Array.isArray(scp) ? scp.filter((one): one is string => typeof one === 'string') : [];
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const readAudience = (value: unknown): readonly string[] => {
|
|
70
|
+
if (typeof value === 'string') return [value];
|
|
71
|
+
return Array.isArray(value) ? value.filter((one): one is string => typeof one === 'string') : [];
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Signature first, then issuer, audience and the two time bounds. The order matters for the same
|
|
76
|
+
* reason it does in `verifyIdToken`: a claim is only worth reading once something proved who
|
|
77
|
+
* wrote it, and every check below would otherwise pass for a token the caller minted themselves.
|
|
78
|
+
*/
|
|
79
|
+
export async function verifyWorkloadToken(input: VerifyWorkloadTokenInput): Promise<WorkloadToken> {
|
|
80
|
+
if (!(await verifyJwtSignature(input.token, input.keys))) {
|
|
81
|
+
throw refused('the signature does not verify against the published key set');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const payloadSegment = input.token.split('.')[1];
|
|
85
|
+
const parsed = payloadSegment === undefined ? null : decodeJwtSegment(payloadSegment);
|
|
86
|
+
if (parsed === null) {
|
|
87
|
+
throw refused('the payload is not base64url-encoded JSON describing an object');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const iss = parsed['iss'];
|
|
91
|
+
const sub = parsed['sub'];
|
|
92
|
+
const exp = parsed['exp'];
|
|
93
|
+
if (typeof iss !== 'string' || typeof sub !== 'string' || sub === '') {
|
|
94
|
+
throw refused('the payload carries no iss or sub');
|
|
95
|
+
}
|
|
96
|
+
if (typeof exp !== 'number') throw refused('the payload carries no numeric exp');
|
|
97
|
+
if (!input.issuers.includes(iss)) throw refused('the issuer is not one this service accepts');
|
|
98
|
+
|
|
99
|
+
const aud = readAudience(parsed['aud']);
|
|
100
|
+
if (!aud.includes(input.audience)) throw refused('the token is addressed to another audience');
|
|
101
|
+
|
|
102
|
+
const nowMs = input.clock.now().getTime();
|
|
103
|
+
// The same skew the id token path allows, from the same declaration: two servers rarely agree
|
|
104
|
+
// on the second, and a second number here would drift from that one.
|
|
105
|
+
if (exp * 1000 + ID_TOKEN_CLOCK_SKEW_MS <= nowMs) throw refused('the token is already expired');
|
|
106
|
+
const nbf = parsed['nbf'];
|
|
107
|
+
if (typeof nbf === 'number' && nbf * 1000 - ID_TOKEN_CLOCK_SKEW_MS > nowMs) {
|
|
108
|
+
throw refused('the token is not valid yet');
|
|
109
|
+
}
|
|
110
|
+
const iat = parsed['iat'];
|
|
111
|
+
|
|
112
|
+
const claims: WorkloadClaims = {
|
|
113
|
+
iss,
|
|
114
|
+
sub,
|
|
115
|
+
aud,
|
|
116
|
+
exp,
|
|
117
|
+
scopes: readScopes(parsed),
|
|
118
|
+
...(typeof nbf === 'number' ? { nbf } : {}),
|
|
119
|
+
...(typeof iat === 'number' ? { iat } : {}),
|
|
120
|
+
};
|
|
121
|
+
return {
|
|
122
|
+
claims,
|
|
123
|
+
identity: {
|
|
124
|
+
// The SPIFFE ID, the service account's `system:serviceaccount:ns:name`, or the IMDS
|
|
125
|
+
// principal — whichever the issuer put there. Never rewritten, so a trace names the caller.
|
|
126
|
+
id: claims.sub,
|
|
127
|
+
scopes: claims.scopes,
|
|
128
|
+
...(input.orgId === undefined ? {} : { orgId: input.orgId }),
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
}
|