actor-gate 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/package.json +25 -0
- package/src/config/base-config.d.ts +17 -0
- package/src/config/base-config.js +33 -0
- package/src/config/index.d.ts +5 -0
- package/src/config/index.js +5 -0
- package/src/config/nextjs-public-config.d.ts +46 -0
- package/src/config/nextjs-public-config.js +89 -0
- package/src/config/nextjs-server-config.d.ts +32 -0
- package/src/config/nextjs-server-config.js +10 -0
- package/src/config/react-client.d.ts +23 -0
- package/src/config/react-client.js +69 -0
- package/src/config/react-config.d.ts +18 -0
- package/src/config/react-config.js +38 -0
- package/src/core/adapters/access-token-revocation-adapter.d.ts +8 -0
- package/src/core/adapters/access-token-revocation-adapter.js +1 -0
- package/src/core/adapters/access-token-transport-adapter.d.ts +15 -0
- package/src/core/adapters/access-token-transport-adapter.js +1 -0
- package/src/core/adapters/authorization-code-adapter.d.ts +21 -0
- package/src/core/adapters/authorization-code-adapter.js +1 -0
- package/src/core/adapters/authorization-hooks.d.ts +13 -0
- package/src/core/adapters/authorization-hooks.js +1 -0
- package/src/core/adapters/index.d.ts +14 -0
- package/src/core/adapters/index.js +1 -0
- package/src/core/adapters/login-method-adapter.d.ts +7 -0
- package/src/core/adapters/login-method-adapter.js +1 -0
- package/src/core/adapters/oauth-client-adapter.d.ts +13 -0
- package/src/core/adapters/oauth-client-adapter.js +1 -0
- package/src/core/adapters/oauth-client-management-adapter.d.ts +23 -0
- package/src/core/adapters/oauth-client-management-adapter.js +1 -0
- package/src/core/adapters/oauth-grant-type.d.ts +1 -0
- package/src/core/adapters/oauth-grant-type.js +1 -0
- package/src/core/adapters/oauth-policy.d.ts +9 -0
- package/src/core/adapters/oauth-policy.js +1 -0
- package/src/core/adapters/observability-hooks.d.ts +31 -0
- package/src/core/adapters/observability-hooks.js +1 -0
- package/src/core/adapters/pending-auth-request-adapter.d.ts +18 -0
- package/src/core/adapters/pending-auth-request-adapter.js +1 -0
- package/src/core/adapters/refresh-token-adapter.d.ts +24 -0
- package/src/core/adapters/refresh-token-adapter.js +1 -0
- package/src/core/adapters/session-adapter.d.ts +14 -0
- package/src/core/adapters/session-adapter.js +1 -0
- package/src/core/adapters/token-adapter.d.ts +15 -0
- package/src/core/adapters/token-adapter.js +1 -0
- package/src/core/http/bearer-challenge.d.ts +6 -0
- package/src/core/http/bearer-challenge.js +16 -0
- package/src/core/ids/id-codec.d.ts +6 -0
- package/src/core/ids/id-codec.js +30 -0
- package/src/core/index.d.ts +9 -0
- package/src/core/index.js +7 -0
- package/src/core/oauth/pkce.d.ts +9 -0
- package/src/core/oauth/pkce.js +30 -0
- package/src/core/services/access-token-service.d.ts +42 -0
- package/src/core/services/access-token-service.js +304 -0
- package/src/core/services/auth-error.d.ts +14 -0
- package/src/core/services/auth-error.js +47 -0
- package/src/core/services/contracts.d.ts +23 -0
- package/src/core/services/contracts.js +1 -0
- package/src/core/services/direct-auth-service.d.ts +50 -0
- package/src/core/services/direct-auth-service.js +267 -0
- package/src/core/services/index.d.ts +7 -0
- package/src/core/services/index.js +5 -0
- package/src/core/services/mcp-auth-service.d.ts +39 -0
- package/src/core/services/mcp-auth-service.js +170 -0
- package/src/core/services/oauth-service.d.ts +91 -0
- package/src/core/services/oauth-service.js +571 -0
- package/src/core/services/observability.d.ts +22 -0
- package/src/core/services/observability.js +71 -0
- package/src/core/services/revocation-policy.d.ts +21 -0
- package/src/core/services/revocation-policy.js +51 -0
- package/src/core/sessions/client-session.d.ts +7 -0
- package/src/core/sessions/client-session.js +18 -0
- package/src/core/tokens/access-claims.d.ts +21 -0
- package/src/core/tokens/access-claims.js +128 -0
- package/src/core/tokens/id-claims.d.ts +20 -0
- package/src/core/tokens/id-claims.js +25 -0
- package/src/core/types/auth-contract.d.ts +33 -0
- package/src/core/types/auth-contract.js +1 -0
- package/src/express/index.d.ts +1 -0
- package/src/express/index.js +1 -0
- package/src/express/protected-route.d.ts +44 -0
- package/src/express/protected-route.js +119 -0
- package/src/index.d.ts +8 -0
- package/src/index.js +8 -0
- package/src/mcp/index.d.ts +1 -0
- package/src/mcp/index.js +1 -0
- package/src/mcp/json-rpc-auth.d.ts +5 -0
- package/src/mcp/json-rpc-auth.js +41 -0
- package/src/next/app/catch-all.d.ts +32 -0
- package/src/next/app/catch-all.js +82 -0
- package/src/next/app/cookies.d.ts +22 -0
- package/src/next/app/cookies.js +36 -0
- package/src/next/app/direct-auth-handlers.d.ts +55 -0
- package/src/next/app/direct-auth-handlers.js +419 -0
- package/src/next/app/index.d.ts +8 -0
- package/src/next/app/index.js +8 -0
- package/src/next/app/mcp-oauth-handlers.d.ts +74 -0
- package/src/next/app/mcp-oauth-handlers.js +365 -0
- package/src/next/app/protected-route.d.ts +27 -0
- package/src/next/app/protected-route.js +59 -0
- package/src/next/app/request.d.ts +12 -0
- package/src/next/app/request.js +30 -0
- package/src/next/app/response.d.ts +16 -0
- package/src/next/app/response.js +48 -0
- package/src/next/app/wrapper.d.ts +28 -0
- package/src/next/app/wrapper.js +78 -0
- package/src/next/index.d.ts +6 -0
- package/src/next/index.js +5 -0
- package/src/next/pages/catch-all.d.ts +19 -0
- package/src/next/pages/catch-all.js +60 -0
- package/src/next/pages/cookies.d.ts +41 -0
- package/src/next/pages/cookies.js +87 -0
- package/src/next/pages/direct-auth-handlers.d.ts +58 -0
- package/src/next/pages/direct-auth-handlers.js +425 -0
- package/src/next/pages/index.d.ts +8 -0
- package/src/next/pages/index.js +8 -0
- package/src/next/pages/mcp-oauth-handlers.d.ts +77 -0
- package/src/next/pages/mcp-oauth-handlers.js +341 -0
- package/src/next/pages/protected-route.d.ts +28 -0
- package/src/next/pages/protected-route.js +59 -0
- package/src/next/pages/request.d.ts +14 -0
- package/src/next/pages/request.js +66 -0
- package/src/next/pages/response.d.ts +28 -0
- package/src/next/pages/response.js +29 -0
- package/src/next/pages/wrapper.d.ts +29 -0
- package/src/next/pages/wrapper.js +74 -0
- package/src/next/rewrites.d.ts +12 -0
- package/src/next/rewrites.js +74 -0
- package/src/next/shared/auth-http.d.ts +24 -0
- package/src/next/shared/auth-http.js +42 -0
- package/src/next/shared/auth-routes.d.ts +17 -0
- package/src/next/shared/auth-routes.js +153 -0
- package/src/next/shared/direct-auth-utils.d.ts +71 -0
- package/src/next/shared/direct-auth-utils.js +275 -0
- package/src/next/shared/oauth-utils.d.ts +45 -0
- package/src/next/shared/oauth-utils.js +308 -0
- package/src/next/shared/well-known-utils.d.ts +46 -0
- package/src/next/shared/well-known-utils.js +108 -0
- package/src/testing/in-memory/in-memory-access-token-revocation-adapter.d.ts +2 -0
- package/src/testing/in-memory/in-memory-access-token-revocation-adapter.js +14 -0
- package/src/testing/in-memory/in-memory-authorization-code-adapter.d.ts +2 -0
- package/src/testing/in-memory/in-memory-authorization-code-adapter.js +36 -0
- package/src/testing/in-memory/in-memory-oauth-client-adapter.d.ts +14 -0
- package/src/testing/in-memory/in-memory-oauth-client-adapter.js +26 -0
- package/src/testing/in-memory/in-memory-pending-auth-request-adapter.d.ts +2 -0
- package/src/testing/in-memory/in-memory-pending-auth-request-adapter.js +43 -0
- package/src/testing/in-memory/in-memory-refresh-token-adapter.d.ts +2 -0
- package/src/testing/in-memory/in-memory-refresh-token-adapter.js +67 -0
- package/src/testing/in-memory/in-memory-session-adapter.d.ts +6 -0
- package/src/testing/in-memory/in-memory-session-adapter.js +43 -0
- package/src/testing/in-memory/index.d.ts +7 -0
- package/src/testing/in-memory/index.js +7 -0
- package/src/testing/in-memory/test-fixtures.d.ts +5 -0
- package/src/testing/in-memory/test-fixtures.js +18 -0
- package/src/testing/index.d.ts +2 -0
- package/src/testing/index.js +4 -0
|
@@ -0,0 +1,571 @@
|
|
|
1
|
+
import { verifyPkce } from '../oauth/pkce';
|
|
2
|
+
import { AuthServiceError, isAuthServiceError } from './auth-error';
|
|
3
|
+
import { emitAuditEvent, reportServiceError } from './observability';
|
|
4
|
+
function assertPositiveSafeInteger(value, fieldName) {
|
|
5
|
+
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
6
|
+
throw new AuthServiceError({
|
|
7
|
+
code: 'invalid_request',
|
|
8
|
+
message: `${fieldName} must be a positive safe integer.`,
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export function createOAuthService(input) {
|
|
13
|
+
const nowUnix = input.nowUnix ?? (() => Math.floor(Date.now() / 1000));
|
|
14
|
+
assertPositiveSafeInteger(input.authorizationCodeTtlSeconds, 'authorizationCodeTtlSeconds');
|
|
15
|
+
assertPositiveSafeInteger(input.pendingAuthRequestTtlSeconds, 'pendingAuthRequestTtlSeconds');
|
|
16
|
+
if (input.refreshTokenAdapter) {
|
|
17
|
+
if (input.refreshTokenTtlSeconds === undefined) {
|
|
18
|
+
throw new AuthServiceError({
|
|
19
|
+
code: 'invalid_request',
|
|
20
|
+
message: 'refreshTokenTtlSeconds is required when refreshTokenAdapter is configured.',
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
assertPositiveSafeInteger(input.refreshTokenTtlSeconds, 'refreshTokenTtlSeconds');
|
|
24
|
+
}
|
|
25
|
+
return {
|
|
26
|
+
async startAuthorization({ requestId, clientId, redirectUri, codeChallenge, codeMethod, scopes, state, auditRequestId, }) {
|
|
27
|
+
const now = nowUnix();
|
|
28
|
+
if (requestId.length === 0) {
|
|
29
|
+
throw new AuthServiceError({
|
|
30
|
+
code: 'invalid_request',
|
|
31
|
+
message: 'requestId must be a non-empty string.',
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
if (codeChallenge.length === 0) {
|
|
35
|
+
throw new AuthServiceError({
|
|
36
|
+
code: 'invalid_request',
|
|
37
|
+
message: 'codeChallenge must be a non-empty string.',
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
if (!input.oauthPolicy.isAllowedClientId(clientId)) {
|
|
41
|
+
throw new AuthServiceError({
|
|
42
|
+
code: 'invalid_client',
|
|
43
|
+
message: 'OAuth client is not allowed.',
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
if (!input.oauthPolicy.isAllowedRedirectUri(redirectUri)) {
|
|
47
|
+
throw new AuthServiceError({
|
|
48
|
+
code: 'invalid_request',
|
|
49
|
+
message: 'Redirect URI is not allowed.',
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
if (!scopes.includes(input.oauthPolicy.requiredScopeForMcp)) {
|
|
53
|
+
throw new AuthServiceError({
|
|
54
|
+
code: 'invalid_request',
|
|
55
|
+
message: 'Missing required MCP scope.',
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
const expiresAtUnix = now + input.pendingAuthRequestTtlSeconds;
|
|
60
|
+
await input.pendingAuthRequestAdapter.create({
|
|
61
|
+
requestId,
|
|
62
|
+
payload: {
|
|
63
|
+
clientId,
|
|
64
|
+
redirectUri,
|
|
65
|
+
codeChallenge,
|
|
66
|
+
codeMethod,
|
|
67
|
+
scopes: [...scopes],
|
|
68
|
+
...(state === undefined ? {} : { state }),
|
|
69
|
+
},
|
|
70
|
+
expiresAtUnix,
|
|
71
|
+
});
|
|
72
|
+
await emitAuditEvent({
|
|
73
|
+
observability: input.observability,
|
|
74
|
+
requestId: auditRequestId,
|
|
75
|
+
event: {
|
|
76
|
+
name: 'oauth_authorize_started',
|
|
77
|
+
atUnix: now,
|
|
78
|
+
clientId,
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
return {
|
|
82
|
+
requestId,
|
|
83
|
+
expiresAtUnix,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
if (isAuthServiceError(error)) {
|
|
88
|
+
throw error;
|
|
89
|
+
}
|
|
90
|
+
await reportServiceError({
|
|
91
|
+
observability: input.observability,
|
|
92
|
+
where: 'oauth-service.startAuthorization',
|
|
93
|
+
error,
|
|
94
|
+
requestId: auditRequestId,
|
|
95
|
+
});
|
|
96
|
+
throw new AuthServiceError({
|
|
97
|
+
code: 'system_error',
|
|
98
|
+
message: 'Failed to start OAuth authorization.',
|
|
99
|
+
cause: error,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
async confirmAuthorization({ requestId, userId, approved, auditRequestId, }) {
|
|
104
|
+
const now = nowUnix();
|
|
105
|
+
try {
|
|
106
|
+
const pendingRequest = await input.pendingAuthRequestAdapter.consume(requestId, now);
|
|
107
|
+
if (!pendingRequest) {
|
|
108
|
+
throw new AuthServiceError({
|
|
109
|
+
code: 'invalid_request',
|
|
110
|
+
message: 'Authorization request was not found.',
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
if (pendingRequest.consumedAtUnix !== now) {
|
|
114
|
+
throw new AuthServiceError({
|
|
115
|
+
code: 'invalid_request',
|
|
116
|
+
message: 'Authorization request was already consumed.',
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
if (pendingRequest.expiresAtUnix <= now) {
|
|
120
|
+
throw new AuthServiceError({
|
|
121
|
+
code: 'invalid_request',
|
|
122
|
+
message: 'Authorization request has expired.',
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
if (!approved) {
|
|
126
|
+
await input.pendingAuthRequestAdapter.delete(requestId);
|
|
127
|
+
return {
|
|
128
|
+
approved: false,
|
|
129
|
+
redirectUri: pendingRequest.payload.redirectUri,
|
|
130
|
+
...(pendingRequest.payload.state === undefined
|
|
131
|
+
? {}
|
|
132
|
+
: { state: pendingRequest.payload.state }),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
const authorizationCode = input.generateToken();
|
|
136
|
+
const authorizationCodeHash = input.hashToken(authorizationCode);
|
|
137
|
+
const expiresAtUnix = now + input.authorizationCodeTtlSeconds;
|
|
138
|
+
await input.authorizationCodeAdapter.create({
|
|
139
|
+
userId,
|
|
140
|
+
codeHash: authorizationCodeHash,
|
|
141
|
+
clientId: pendingRequest.payload.clientId,
|
|
142
|
+
redirectUri: pendingRequest.payload.redirectUri,
|
|
143
|
+
codeChallenge: pendingRequest.payload.codeChallenge,
|
|
144
|
+
codeMethod: pendingRequest.payload.codeMethod,
|
|
145
|
+
expiresAtUnix,
|
|
146
|
+
});
|
|
147
|
+
await input.pendingAuthRequestAdapter.delete(requestId);
|
|
148
|
+
await emitAuditEvent({
|
|
149
|
+
observability: input.observability,
|
|
150
|
+
requestId: auditRequestId,
|
|
151
|
+
event: {
|
|
152
|
+
name: 'oauth_code_issued',
|
|
153
|
+
atUnix: now,
|
|
154
|
+
clientId: pendingRequest.payload.clientId,
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
return {
|
|
158
|
+
approved: true,
|
|
159
|
+
authorizationCode,
|
|
160
|
+
redirectUri: pendingRequest.payload.redirectUri,
|
|
161
|
+
...(pendingRequest.payload.state === undefined
|
|
162
|
+
? {}
|
|
163
|
+
: { state: pendingRequest.payload.state }),
|
|
164
|
+
expiresAtUnix,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
catch (error) {
|
|
168
|
+
if (isAuthServiceError(error)) {
|
|
169
|
+
throw error;
|
|
170
|
+
}
|
|
171
|
+
await reportServiceError({
|
|
172
|
+
observability: input.observability,
|
|
173
|
+
where: 'oauth-service.confirmAuthorization',
|
|
174
|
+
error,
|
|
175
|
+
requestId: auditRequestId,
|
|
176
|
+
});
|
|
177
|
+
throw new AuthServiceError({
|
|
178
|
+
code: 'system_error',
|
|
179
|
+
message: 'Failed to confirm OAuth authorization.',
|
|
180
|
+
cause: error,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
},
|
|
184
|
+
async exchangeAuthorizationCode({ clientId, clientSecret, authorizationCode, redirectUri, codeVerifier, sessionTtlSeconds, issueRefreshToken, auditRequestId, }) {
|
|
185
|
+
const now = nowUnix();
|
|
186
|
+
assertPositiveSafeInteger(sessionTtlSeconds, 'sessionTtlSeconds');
|
|
187
|
+
try {
|
|
188
|
+
if (input.oauthPolicy.isAllowedGrantType &&
|
|
189
|
+
!input.oauthPolicy.isAllowedGrantType('authorization_code', clientId)) {
|
|
190
|
+
throw new AuthServiceError({
|
|
191
|
+
code: 'invalid_grant',
|
|
192
|
+
message: 'Grant type is not allowed for this client.',
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
const client = await input.oauthClientAdapter.validateClient({
|
|
196
|
+
clientId,
|
|
197
|
+
clientSecret,
|
|
198
|
+
grantType: 'authorization_code',
|
|
199
|
+
});
|
|
200
|
+
if (!client) {
|
|
201
|
+
throw new AuthServiceError({
|
|
202
|
+
code: 'invalid_client',
|
|
203
|
+
message: 'OAuth client credentials are invalid.',
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
await emitAuditEvent({
|
|
207
|
+
observability: input.observability,
|
|
208
|
+
requestId: auditRequestId,
|
|
209
|
+
event: {
|
|
210
|
+
name: 'oauth_client_authenticated',
|
|
211
|
+
atUnix: now,
|
|
212
|
+
actor: client.actor,
|
|
213
|
+
clientId: client.clientId,
|
|
214
|
+
},
|
|
215
|
+
});
|
|
216
|
+
if (!input.oauthPolicy.isAllowedActor(client.actor)) {
|
|
217
|
+
throw new AuthServiceError({
|
|
218
|
+
code: 'forbidden',
|
|
219
|
+
message: 'OAuth actor is not allowed.',
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
const code = await input.authorizationCodeAdapter.consume(input.hashToken(authorizationCode), now);
|
|
223
|
+
if (!code) {
|
|
224
|
+
throw new AuthServiceError({
|
|
225
|
+
code: 'invalid_grant',
|
|
226
|
+
message: 'Authorization code is invalid.',
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
if (code.consumedAtUnix !== now) {
|
|
230
|
+
throw new AuthServiceError({
|
|
231
|
+
code: 'invalid_grant',
|
|
232
|
+
message: 'Authorization code was already consumed.',
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
if (code.expiresAtUnix <= now) {
|
|
236
|
+
throw new AuthServiceError({
|
|
237
|
+
code: 'invalid_grant',
|
|
238
|
+
message: 'Authorization code has expired.',
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
if (code.clientId !== client.clientId) {
|
|
242
|
+
throw new AuthServiceError({
|
|
243
|
+
code: 'invalid_grant',
|
|
244
|
+
message: 'Authorization code client mismatch.',
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
if (code.redirectUri !== redirectUri) {
|
|
248
|
+
throw new AuthServiceError({
|
|
249
|
+
code: 'invalid_grant',
|
|
250
|
+
message: 'Authorization code redirect URI mismatch.',
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
if (!verifyPkce({
|
|
254
|
+
codeVerifier,
|
|
255
|
+
codeChallenge: code.codeChallenge,
|
|
256
|
+
codeMethod: code.codeMethod,
|
|
257
|
+
})) {
|
|
258
|
+
throw new AuthServiceError({
|
|
259
|
+
code: 'invalid_grant',
|
|
260
|
+
message: 'PKCE verification failed.',
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
const session = await input.sessionAdapter.create({
|
|
264
|
+
userId: code.userId,
|
|
265
|
+
actor: client.actor,
|
|
266
|
+
clientId: client.clientId,
|
|
267
|
+
ttlSeconds: sessionTtlSeconds,
|
|
268
|
+
});
|
|
269
|
+
const issued = await input.accessTokenService.issueAccessToken({
|
|
270
|
+
session,
|
|
271
|
+
...(auditRequestId === undefined
|
|
272
|
+
? {}
|
|
273
|
+
: { requestId: auditRequestId }),
|
|
274
|
+
});
|
|
275
|
+
const shouldIssueRefreshToken = issueRefreshToken ?? input.refreshTokenAdapter !== undefined;
|
|
276
|
+
let refreshToken;
|
|
277
|
+
if (shouldIssueRefreshToken) {
|
|
278
|
+
if (!input.refreshTokenAdapter ||
|
|
279
|
+
input.refreshTokenTtlSeconds === undefined) {
|
|
280
|
+
throw new AuthServiceError({
|
|
281
|
+
code: 'invalid_request',
|
|
282
|
+
message: 'Refresh-token exchange is not configured for this OAuth service.',
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
refreshToken = input.generateToken();
|
|
286
|
+
await input.refreshTokenAdapter.create({
|
|
287
|
+
sessionId: session.sessionId,
|
|
288
|
+
tokenHash: input.hashToken(refreshToken),
|
|
289
|
+
expiresAtUnix: now + input.refreshTokenTtlSeconds,
|
|
290
|
+
familyId: input.generateToken(),
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
await emitAuditEvent({
|
|
294
|
+
observability: input.observability,
|
|
295
|
+
requestId: auditRequestId,
|
|
296
|
+
event: {
|
|
297
|
+
name: 'oauth_token_exchanged',
|
|
298
|
+
atUnix: now,
|
|
299
|
+
actor: client.actor,
|
|
300
|
+
clientId: client.clientId,
|
|
301
|
+
sessionId: issued.claims.sub,
|
|
302
|
+
},
|
|
303
|
+
});
|
|
304
|
+
return {
|
|
305
|
+
accessToken: issued.accessToken,
|
|
306
|
+
accessClaims: issued.claims,
|
|
307
|
+
...(refreshToken === undefined ? {} : { refreshToken }),
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
catch (error) {
|
|
311
|
+
if (isAuthServiceError(error)) {
|
|
312
|
+
throw error;
|
|
313
|
+
}
|
|
314
|
+
await reportServiceError({
|
|
315
|
+
observability: input.observability,
|
|
316
|
+
where: 'oauth-service.exchangeAuthorizationCode',
|
|
317
|
+
error,
|
|
318
|
+
requestId: auditRequestId,
|
|
319
|
+
});
|
|
320
|
+
throw new AuthServiceError({
|
|
321
|
+
code: 'system_error',
|
|
322
|
+
message: 'Failed to exchange authorization code.',
|
|
323
|
+
cause: error,
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
},
|
|
327
|
+
async exchangeClientCredentials({ clientId, clientSecret, sessionTtlSeconds, auditRequestId, }) {
|
|
328
|
+
const now = nowUnix();
|
|
329
|
+
assertPositiveSafeInteger(sessionTtlSeconds, 'sessionTtlSeconds');
|
|
330
|
+
try {
|
|
331
|
+
if (input.oauthPolicy.isAllowedGrantType &&
|
|
332
|
+
!input.oauthPolicy.isAllowedGrantType('client_credentials', clientId)) {
|
|
333
|
+
throw new AuthServiceError({
|
|
334
|
+
code: 'invalid_grant',
|
|
335
|
+
message: 'Grant type is not allowed for this client.',
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
const client = await input.oauthClientAdapter.validateClient({
|
|
339
|
+
clientId,
|
|
340
|
+
clientSecret,
|
|
341
|
+
grantType: 'client_credentials',
|
|
342
|
+
});
|
|
343
|
+
if (!client) {
|
|
344
|
+
throw new AuthServiceError({
|
|
345
|
+
code: 'invalid_client',
|
|
346
|
+
message: 'OAuth client credentials are invalid.',
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
await emitAuditEvent({
|
|
350
|
+
observability: input.observability,
|
|
351
|
+
requestId: auditRequestId,
|
|
352
|
+
event: {
|
|
353
|
+
name: 'oauth_client_authenticated',
|
|
354
|
+
atUnix: now,
|
|
355
|
+
actor: client.actor,
|
|
356
|
+
clientId: client.clientId,
|
|
357
|
+
},
|
|
358
|
+
});
|
|
359
|
+
if (!input.oauthPolicy.isAllowedActor(client.actor)) {
|
|
360
|
+
throw new AuthServiceError({
|
|
361
|
+
code: 'forbidden',
|
|
362
|
+
message: 'OAuth actor is not allowed.',
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
const session = await input.sessionAdapter.create({
|
|
366
|
+
userId: client.userId,
|
|
367
|
+
actor: client.actor,
|
|
368
|
+
clientId: client.clientId,
|
|
369
|
+
ttlSeconds: sessionTtlSeconds,
|
|
370
|
+
});
|
|
371
|
+
const issued = await input.accessTokenService.issueAccessToken({
|
|
372
|
+
session,
|
|
373
|
+
...(auditRequestId === undefined
|
|
374
|
+
? {}
|
|
375
|
+
: { requestId: auditRequestId }),
|
|
376
|
+
});
|
|
377
|
+
await emitAuditEvent({
|
|
378
|
+
observability: input.observability,
|
|
379
|
+
requestId: auditRequestId,
|
|
380
|
+
event: {
|
|
381
|
+
name: 'oauth_token_exchanged',
|
|
382
|
+
atUnix: now,
|
|
383
|
+
actor: client.actor,
|
|
384
|
+
clientId: client.clientId,
|
|
385
|
+
sessionId: issued.claims.sub,
|
|
386
|
+
},
|
|
387
|
+
});
|
|
388
|
+
return {
|
|
389
|
+
accessToken: issued.accessToken,
|
|
390
|
+
accessClaims: issued.claims,
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
catch (error) {
|
|
394
|
+
if (isAuthServiceError(error)) {
|
|
395
|
+
throw error;
|
|
396
|
+
}
|
|
397
|
+
await reportServiceError({
|
|
398
|
+
observability: input.observability,
|
|
399
|
+
where: 'oauth-service.exchangeClientCredentials',
|
|
400
|
+
error,
|
|
401
|
+
requestId: auditRequestId,
|
|
402
|
+
});
|
|
403
|
+
throw new AuthServiceError({
|
|
404
|
+
code: 'system_error',
|
|
405
|
+
message: 'Failed to exchange client credentials.',
|
|
406
|
+
cause: error,
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
},
|
|
410
|
+
async exchangeRefreshToken({ clientId, clientSecret, refreshToken, auditRequestId, }) {
|
|
411
|
+
const now = nowUnix();
|
|
412
|
+
if (!input.refreshTokenAdapter ||
|
|
413
|
+
input.refreshTokenTtlSeconds === undefined) {
|
|
414
|
+
throw new AuthServiceError({
|
|
415
|
+
code: 'invalid_request',
|
|
416
|
+
message: 'Refresh token support is not configured.',
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
try {
|
|
420
|
+
if (input.oauthPolicy.isAllowedGrantType &&
|
|
421
|
+
!input.oauthPolicy.isAllowedGrantType('refresh_token', clientId)) {
|
|
422
|
+
throw new AuthServiceError({
|
|
423
|
+
code: 'invalid_grant',
|
|
424
|
+
message: 'Grant type is not allowed for this client.',
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
const client = await input.oauthClientAdapter.validateClient({
|
|
428
|
+
clientId,
|
|
429
|
+
clientSecret,
|
|
430
|
+
grantType: 'refresh_token',
|
|
431
|
+
});
|
|
432
|
+
if (!client) {
|
|
433
|
+
throw new AuthServiceError({
|
|
434
|
+
code: 'invalid_client',
|
|
435
|
+
message: 'OAuth client credentials are invalid.',
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
if (!input.oauthPolicy.isAllowedActor(client.actor)) {
|
|
439
|
+
throw new AuthServiceError({
|
|
440
|
+
code: 'forbidden',
|
|
441
|
+
message: 'OAuth actor is not allowed.',
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
const providedRefreshTokenHash = input.hashToken(refreshToken);
|
|
445
|
+
const token = await input.refreshTokenAdapter.consume(providedRefreshTokenHash, now);
|
|
446
|
+
if (!token) {
|
|
447
|
+
throw new AuthServiceError({
|
|
448
|
+
code: 'invalid_grant',
|
|
449
|
+
message: 'Refresh token is invalid.',
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
if (token.consumedAtUnix !== now) {
|
|
453
|
+
await input.refreshTokenAdapter.revokeFamily(token.familyId, now);
|
|
454
|
+
await emitAuditEvent({
|
|
455
|
+
observability: input.observability,
|
|
456
|
+
requestId: auditRequestId,
|
|
457
|
+
event: {
|
|
458
|
+
name: 'refresh_token_reuse_detected',
|
|
459
|
+
atUnix: now,
|
|
460
|
+
actor: client.actor,
|
|
461
|
+
clientId: client.clientId,
|
|
462
|
+
},
|
|
463
|
+
});
|
|
464
|
+
throw new AuthServiceError({
|
|
465
|
+
code: 'invalid_grant',
|
|
466
|
+
message: 'Refresh token has already been consumed.',
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
if (token.revokedAtUnix !== undefined &&
|
|
470
|
+
token.revokedAtUnix !== null &&
|
|
471
|
+
token.revokedAtUnix <= now) {
|
|
472
|
+
throw new AuthServiceError({
|
|
473
|
+
code: 'invalid_grant',
|
|
474
|
+
message: 'Refresh token has been revoked.',
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
if (token.expiresAtUnix <= now) {
|
|
478
|
+
throw new AuthServiceError({
|
|
479
|
+
code: 'invalid_grant',
|
|
480
|
+
message: 'Refresh token has expired.',
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
const session = await input.sessionAdapter.findById(token.sessionId);
|
|
484
|
+
if (!session) {
|
|
485
|
+
throw new AuthServiceError({
|
|
486
|
+
code: 'invalid_grant',
|
|
487
|
+
message: 'Session for refresh token was not found.',
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
if (session.userId !== client.userId ||
|
|
491
|
+
session.actor !== client.actor ||
|
|
492
|
+
session.clientId !== client.clientId) {
|
|
493
|
+
throw new AuthServiceError({
|
|
494
|
+
code: 'invalid_grant',
|
|
495
|
+
message: 'Refresh token does not belong to this client identity.',
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
if (session.revokedAt !== undefined &&
|
|
499
|
+
session.revokedAt !== null &&
|
|
500
|
+
session.revokedAt <= now) {
|
|
501
|
+
throw new AuthServiceError({
|
|
502
|
+
code: 'invalid_grant',
|
|
503
|
+
message: 'Session has been revoked.',
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
if (session.expiresAt <= now) {
|
|
507
|
+
throw new AuthServiceError({
|
|
508
|
+
code: 'invalid_grant',
|
|
509
|
+
message: 'Session has expired.',
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
const issued = await input.accessTokenService.issueAccessToken({
|
|
513
|
+
session,
|
|
514
|
+
...(auditRequestId === undefined
|
|
515
|
+
? {}
|
|
516
|
+
: { requestId: auditRequestId }),
|
|
517
|
+
});
|
|
518
|
+
const rotatedRefreshToken = input.generateToken();
|
|
519
|
+
await input.refreshTokenAdapter.create({
|
|
520
|
+
sessionId: session.sessionId,
|
|
521
|
+
tokenHash: input.hashToken(rotatedRefreshToken),
|
|
522
|
+
expiresAtUnix: now + input.refreshTokenTtlSeconds,
|
|
523
|
+
familyId: token.familyId,
|
|
524
|
+
parentTokenHash: providedRefreshTokenHash,
|
|
525
|
+
});
|
|
526
|
+
await emitAuditEvent({
|
|
527
|
+
observability: input.observability,
|
|
528
|
+
requestId: auditRequestId,
|
|
529
|
+
event: {
|
|
530
|
+
name: 'refresh_token_rotated',
|
|
531
|
+
atUnix: now,
|
|
532
|
+
actor: client.actor,
|
|
533
|
+
clientId: client.clientId,
|
|
534
|
+
},
|
|
535
|
+
});
|
|
536
|
+
await emitAuditEvent({
|
|
537
|
+
observability: input.observability,
|
|
538
|
+
requestId: auditRequestId,
|
|
539
|
+
event: {
|
|
540
|
+
name: 'oauth_token_exchanged',
|
|
541
|
+
atUnix: now,
|
|
542
|
+
actor: client.actor,
|
|
543
|
+
clientId: client.clientId,
|
|
544
|
+
sessionId: issued.claims.sub,
|
|
545
|
+
},
|
|
546
|
+
});
|
|
547
|
+
return {
|
|
548
|
+
accessToken: issued.accessToken,
|
|
549
|
+
accessClaims: issued.claims,
|
|
550
|
+
refreshToken: rotatedRefreshToken,
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
catch (error) {
|
|
554
|
+
if (isAuthServiceError(error)) {
|
|
555
|
+
throw error;
|
|
556
|
+
}
|
|
557
|
+
await reportServiceError({
|
|
558
|
+
observability: input.observability,
|
|
559
|
+
where: 'oauth-service.exchangeRefreshToken',
|
|
560
|
+
error,
|
|
561
|
+
requestId: auditRequestId,
|
|
562
|
+
});
|
|
563
|
+
throw new AuthServiceError({
|
|
564
|
+
code: 'system_error',
|
|
565
|
+
message: 'Failed to exchange refresh token.',
|
|
566
|
+
cause: error,
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
},
|
|
570
|
+
};
|
|
571
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { AuthAuditEvent, ObservabilityConfig } from '../adapters/observability-hooks';
|
|
2
|
+
import type { AuthActor } from '../types/auth-contract';
|
|
3
|
+
export declare function emitAuditEvent<TActor extends AuthActor>(input: {
|
|
4
|
+
observability: ObservabilityConfig<TActor> | undefined;
|
|
5
|
+
event: AuthAuditEvent<TActor>;
|
|
6
|
+
requestId: string | undefined;
|
|
7
|
+
}): Promise<void>;
|
|
8
|
+
export declare function emitMetric<TActor extends AuthActor>(input: {
|
|
9
|
+
observability: ObservabilityConfig<TActor> | undefined;
|
|
10
|
+
metric: {
|
|
11
|
+
name: string;
|
|
12
|
+
value?: number;
|
|
13
|
+
tags?: Record<string, string>;
|
|
14
|
+
};
|
|
15
|
+
requestId: string | undefined;
|
|
16
|
+
}): Promise<void>;
|
|
17
|
+
export declare function reportServiceError<TActor extends AuthActor>(input: {
|
|
18
|
+
observability: ObservabilityConfig<TActor> | undefined;
|
|
19
|
+
where: string;
|
|
20
|
+
error: unknown;
|
|
21
|
+
requestId: string | undefined;
|
|
22
|
+
}): Promise<void>;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
async function invokeHook(input) {
|
|
2
|
+
if (!input.hook) {
|
|
3
|
+
return;
|
|
4
|
+
}
|
|
5
|
+
try {
|
|
6
|
+
await input.hook();
|
|
7
|
+
}
|
|
8
|
+
catch (hookError) {
|
|
9
|
+
const onError = input.observability?.hooks?.onError;
|
|
10
|
+
if (onError) {
|
|
11
|
+
try {
|
|
12
|
+
await onError({
|
|
13
|
+
where: input.where,
|
|
14
|
+
error: hookError,
|
|
15
|
+
...(input.requestId === undefined
|
|
16
|
+
? {}
|
|
17
|
+
: { requestId: input.requestId }),
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export async function emitAuditEvent(input) {
|
|
27
|
+
const onAuditEvent = input.observability?.hooks?.onAuditEvent;
|
|
28
|
+
await invokeHook({
|
|
29
|
+
observability: input.observability,
|
|
30
|
+
where: 'observability.onAuditEvent',
|
|
31
|
+
requestId: input.requestId,
|
|
32
|
+
...(onAuditEvent
|
|
33
|
+
? {
|
|
34
|
+
hook: async () => {
|
|
35
|
+
await onAuditEvent(input.event);
|
|
36
|
+
},
|
|
37
|
+
}
|
|
38
|
+
: {}),
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
export async function emitMetric(input) {
|
|
42
|
+
const onMetric = input.observability?.hooks?.onMetric;
|
|
43
|
+
await invokeHook({
|
|
44
|
+
observability: input.observability,
|
|
45
|
+
where: 'observability.onMetric',
|
|
46
|
+
requestId: input.requestId,
|
|
47
|
+
...(onMetric
|
|
48
|
+
? {
|
|
49
|
+
hook: async () => {
|
|
50
|
+
await onMetric(input.metric);
|
|
51
|
+
},
|
|
52
|
+
}
|
|
53
|
+
: {}),
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
export async function reportServiceError(input) {
|
|
57
|
+
const onError = input.observability?.hooks?.onError;
|
|
58
|
+
if (!onError) {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
await onError({
|
|
63
|
+
where: input.where,
|
|
64
|
+
error: input.error,
|
|
65
|
+
...(input.requestId === undefined ? {} : { requestId: input.requestId }),
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
// Observability failures are intentionally non-blocking.
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { AccessTokenRevocationAdapter } from '../adapters/access-token-revocation-adapter';
|
|
2
|
+
export type SessionMode = 'stateful' | 'stateless' | 'hybrid';
|
|
3
|
+
export type RevocationGuarantee = 'strict' | 'bounded' | 'ttl_only';
|
|
4
|
+
export declare function shouldValidateSession(input: {
|
|
5
|
+
sessionMode: SessionMode;
|
|
6
|
+
hasSessionAdapter: boolean;
|
|
7
|
+
}): boolean;
|
|
8
|
+
export declare function shouldCheckAccessTokenRevocation(input: {
|
|
9
|
+
revocationGuarantee: RevocationGuarantee;
|
|
10
|
+
hasRevocationAdapter: boolean;
|
|
11
|
+
}): boolean;
|
|
12
|
+
export type RevocationDecisionEngine = {
|
|
13
|
+
isAccessTokenRevoked(input: {
|
|
14
|
+
jti: string;
|
|
15
|
+
nowUnix: number;
|
|
16
|
+
revocationGuarantee: RevocationGuarantee;
|
|
17
|
+
boundedRevocationCacheSeconds: number;
|
|
18
|
+
accessTokenRevocationAdapter?: AccessTokenRevocationAdapter;
|
|
19
|
+
}): Promise<boolean>;
|
|
20
|
+
};
|
|
21
|
+
export declare function createRevocationDecisionEngine(): RevocationDecisionEngine;
|