@sentropic/auth-hono 0.4.0 → 0.6.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/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/oauth/authorize-handler.d.ts.map +1 -1
- package/dist/oauth/authorize-handler.js +46 -1
- package/dist/oauth/authorize-handler.js.map +1 -1
- package/dist/oauth/consent-decision-handler.d.ts.map +1 -1
- package/dist/oauth/consent-decision-handler.js +1 -0
- package/dist/oauth/consent-decision-handler.js.map +1 -1
- package/dist/oauth/dpop.d.ts +7 -4
- package/dist/oauth/dpop.d.ts.map +1 -1
- package/dist/oauth/dpop.js +23 -44
- package/dist/oauth/dpop.js.map +1 -1
- package/dist/oauth/jwks-service.d.ts.map +1 -1
- package/dist/oauth/jwks-service.js +6 -8
- package/dist/oauth/jwks-service.js.map +1 -1
- package/dist/oauth/service-auth-middleware.d.ts.map +1 -1
- package/dist/oauth/service-auth-middleware.js +46 -64
- package/dist/oauth/service-auth-middleware.js.map +1 -1
- package/dist/oauth/state-codec.d.ts +2 -0
- package/dist/oauth/state-codec.d.ts.map +1 -1
- package/dist/oauth/state-codec.js.map +1 -1
- package/dist/oauth/state-store-types.d.ts +12 -0
- package/dist/oauth/state-store-types.d.ts.map +1 -1
- package/dist/oauth/token-handler.d.ts.map +1 -1
- package/dist/oauth/token-handler.js +38 -1
- package/dist/oauth/token-handler.js.map +1 -1
- package/dist/oauth/wellknown-handler.js +1 -1
- package/dist/oauth/wellknown-handler.js.map +1 -1
- package/dist/ports.d.ts +14 -0
- package/dist/ports.d.ts.map +1 -1
- package/package.json +4 -1
- package/src/index.ts +10 -0
- package/src/oauth/authorize-handler.ts +55 -1
- package/src/oauth/consent-decision-handler.ts +1 -0
- package/src/oauth/dpop.ts +30 -67
- package/src/oauth/jwks-service.ts +5 -9
- package/src/oauth/service-auth-middleware.ts +59 -80
- package/src/oauth/state-codec.ts +2 -0
- package/src/oauth/state-store-types.ts +12 -0
- package/src/oauth/token-handler.ts +44 -1
- package/src/oauth/wellknown-handler.ts +1 -1
- package/src/ports.ts +15 -0
|
@@ -1,15 +1,21 @@
|
|
|
1
|
+
// COMPAT WRAPPER (architect verdict E2/F8). The CANONICAL home of this RS middleware is now
|
|
2
|
+
// `@sentropic/mcp-auth/hono` (`createRequireServiceAuth`). auth-hono keeps this signature-stable
|
|
3
|
+
// wrapper — same behavior, sharing the SAME verification core (`@sentropic/oauth-verify`), no
|
|
4
|
+
// fourth copy of verify code — for ≥1 minor so pinned RPs are not forced to bump; it is dropped
|
|
5
|
+
// at auth-hono 1.0. The wrapper builds on oauth-verify primitives directly (NOT on mcp-auth) to
|
|
6
|
+
// respect the dependency DAG (auth-hono and mcp-auth never import each other).
|
|
1
7
|
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
8
|
+
DpopVerifyError,
|
|
9
|
+
fromJwksPort,
|
|
10
|
+
parseScopes,
|
|
11
|
+
TokenVerifyError,
|
|
12
|
+
verifyAccessToken,
|
|
13
|
+
verifyDpopProof,
|
|
14
|
+
type AccessTokenClaims,
|
|
15
|
+
} from '@sentropic/oauth-verify';
|
|
9
16
|
import type { Context, MiddlewareHandler } from 'hono';
|
|
10
17
|
|
|
11
18
|
import type { AuthHonoClockPort } from '../ports.js';
|
|
12
|
-
import { sha256Base64url } from './crypto-utils.js';
|
|
13
19
|
import type { JwksPort, OauthStateStorePort } from './state-store-types.js';
|
|
14
20
|
|
|
15
21
|
/**
|
|
@@ -62,7 +68,7 @@ export const createRequireServiceAuth = (
|
|
|
62
68
|
return async (c, next) => {
|
|
63
69
|
try {
|
|
64
70
|
const { scheme, token } = parseAuthorization(c.req.header('authorization'));
|
|
65
|
-
const payload = await
|
|
71
|
+
const payload = await verifyServiceAccessToken(token, options.ports, issuer, options.resource);
|
|
66
72
|
const scopes = parseScopes(payload.scope);
|
|
67
73
|
assertScopes(scopes, requiredScopes);
|
|
68
74
|
|
|
@@ -98,44 +104,33 @@ const parseAuthorization = (header: string | undefined): { scheme: 'Bearer' | 'D
|
|
|
98
104
|
throw new ServiceAuthError(401, 'invalid_token', 'Unsupported authorization scheme.');
|
|
99
105
|
};
|
|
100
106
|
|
|
101
|
-
|
|
107
|
+
/**
|
|
108
|
+
* RS-side access-token verification. Delegates to `@sentropic/oauth-verify`'s shared
|
|
109
|
+
* `verifyAccessToken` over an in-process JWKS key source, mapping any failure onto the
|
|
110
|
+
* RFC 6750 `invalid_token` 401 the middleware emits.
|
|
111
|
+
*/
|
|
112
|
+
const verifyServiceAccessToken = async (
|
|
102
113
|
token: string,
|
|
103
114
|
ports: ServiceAuthPorts,
|
|
104
115
|
issuer: string,
|
|
105
116
|
resource: string
|
|
106
|
-
): Promise<
|
|
107
|
-
let kid: string | undefined;
|
|
117
|
+
): Promise<AccessTokenClaims> => {
|
|
108
118
|
try {
|
|
109
|
-
|
|
110
|
-
} catch {
|
|
111
|
-
throw new ServiceAuthError(401, 'invalid_token', 'Access token header is invalid.');
|
|
112
|
-
}
|
|
113
|
-
if (!kid) {
|
|
114
|
-
throw new ServiceAuthError(401, 'invalid_token', 'Access token is missing a key id.');
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
const key = await ports.jwks.findKeyByKid(kid);
|
|
118
|
-
if (!key) {
|
|
119
|
-
throw new ServiceAuthError(401, 'invalid_token', 'Access token signing key is unknown.');
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
const publicKey = await importJWK(key.publicJwk, key.alg);
|
|
123
|
-
const currentDate = ports.clock.now();
|
|
124
|
-
try {
|
|
125
|
-
const { payload } = await jwtVerify(token, publicKey, {
|
|
119
|
+
return await verifyAccessToken({
|
|
126
120
|
audience: resource,
|
|
127
|
-
currentDate,
|
|
128
121
|
issuer,
|
|
122
|
+
keySource: fromJwksPort(ports.jwks),
|
|
123
|
+
now: ports.clock.now(),
|
|
124
|
+
token,
|
|
129
125
|
});
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
126
|
+
} catch (error) {
|
|
127
|
+
if (error instanceof TokenVerifyError) {
|
|
128
|
+
throw new ServiceAuthError(401, 'invalid_token', 'Access token is invalid, expired, or has the wrong audience.');
|
|
129
|
+
}
|
|
130
|
+
throw error;
|
|
133
131
|
}
|
|
134
132
|
};
|
|
135
133
|
|
|
136
|
-
const parseScopes = (scope: unknown): string[] =>
|
|
137
|
-
typeof scope === 'string' ? scope.split(/\s+/).filter(Boolean) : [];
|
|
138
|
-
|
|
139
134
|
const assertScopes = (scopes: string[], requiredScopes: string[]): void => {
|
|
140
135
|
const granted = new Set(scopes);
|
|
141
136
|
const missing = requiredScopes.filter((scope) => !granted.has(scope));
|
|
@@ -146,7 +141,7 @@ const assertScopes = (scopes: string[], requiredScopes: string[]): void => {
|
|
|
146
141
|
|
|
147
142
|
const enforceDpop = async (
|
|
148
143
|
c: Context,
|
|
149
|
-
payload: { cnf?: { jkt
|
|
144
|
+
payload: { cnf?: { jkt: string } },
|
|
150
145
|
accessToken: string,
|
|
151
146
|
scheme: 'Bearer' | 'DPoP',
|
|
152
147
|
options: CreateRequireServiceAuthOptions
|
|
@@ -188,53 +183,37 @@ interface VerifyServiceDpopProofOptions {
|
|
|
188
183
|
proof: string;
|
|
189
184
|
}
|
|
190
185
|
|
|
186
|
+
/**
|
|
187
|
+
* RS-side DPoP proof verification. Delegates to `@sentropic/oauth-verify`'s shared
|
|
188
|
+
* `verifyDpopProof`, wiring the optional RS replay port and remapping failures onto the
|
|
189
|
+
* RFC 9449 `invalid_dpop_proof` 401. The `jkt`↔`cnf.jkt` binding is enforced by the caller
|
|
190
|
+
* (`enforceDpop`) AFTER replay recording, preserving the original consume-then-compare order.
|
|
191
|
+
*/
|
|
191
192
|
const verifyServiceDpopProof = async (options: VerifyServiceDpopProofOptions): Promise<string> => {
|
|
192
|
-
const
|
|
193
|
-
const publicJwk = header.jwk as JWK | undefined;
|
|
194
|
-
if (!publicJwk || !header.alg || header.typ !== 'dpop+jwt') {
|
|
195
|
-
throw new ServiceAuthError(401, 'invalid_dpop_proof', 'DPoP proof header is invalid.', 'DPoP');
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
const key = await importJWK(publicJwk, header.alg);
|
|
199
|
-
let payload: JWTPayload;
|
|
193
|
+
const iatSkewSec = options.iatSkewSeconds ?? 60;
|
|
200
194
|
try {
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
if (Math.abs(payload.iat - nowSeconds) > skew) {
|
|
221
|
-
throw new ServiceAuthError(401, 'invalid_dpop_proof', 'DPoP iat claim is outside the allowed skew.', 'DPoP');
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
// RFC 9449 §4.3: bind the proof to the access token (BR39d-D7).
|
|
225
|
-
if (payload.ath !== (await sha256Base64url(options.accessToken))) {
|
|
226
|
-
throw new ServiceAuthError(401, 'invalid_dpop_proof', 'DPoP ath claim does not match the access token.', 'DPoP');
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
if (options.ports.dpopReplay) {
|
|
230
|
-
const expiresAt = options.ports.clock.addSeconds(options.ports.clock.now(), skew);
|
|
231
|
-
const recorded = await options.ports.dpopReplay.recordDpopJti(payload.jti, expiresAt);
|
|
232
|
-
if (!recorded) {
|
|
233
|
-
throw new ServiceAuthError(401, 'invalid_dpop_proof', 'DPoP proof jti was already used.', 'DPoP');
|
|
195
|
+
const { jkt } = await verifyDpopProof({
|
|
196
|
+
accessToken: options.accessToken,
|
|
197
|
+
htm: options.htm,
|
|
198
|
+
htu: options.htu,
|
|
199
|
+
iatSkewSec,
|
|
200
|
+
now: options.ports.clock.now(),
|
|
201
|
+
proof: options.proof,
|
|
202
|
+
replay: options.ports.dpopReplay
|
|
203
|
+
? (jti) =>
|
|
204
|
+
options.ports.dpopReplay!.recordDpopJti(
|
|
205
|
+
jti,
|
|
206
|
+
options.ports.clock.addSeconds(options.ports.clock.now(), iatSkewSec)
|
|
207
|
+
)
|
|
208
|
+
: undefined,
|
|
209
|
+
});
|
|
210
|
+
return jkt;
|
|
211
|
+
} catch (error) {
|
|
212
|
+
if (error instanceof DpopVerifyError) {
|
|
213
|
+
throw new ServiceAuthError(401, 'invalid_dpop_proof', error.message, 'DPoP');
|
|
234
214
|
}
|
|
215
|
+
throw error;
|
|
235
216
|
}
|
|
236
|
-
|
|
237
|
-
return calculateJwkThumbprint(publicJwk);
|
|
238
217
|
};
|
|
239
218
|
|
|
240
219
|
const serviceAuthErrorResponse = (c: Context, error: ServiceAuthError): Response => {
|
package/src/oauth/state-codec.ts
CHANGED
|
@@ -9,6 +9,8 @@ export interface OAuthContinuationState {
|
|
|
9
9
|
expiresAt: string;
|
|
10
10
|
nonce: string | null;
|
|
11
11
|
redirectUri: string;
|
|
12
|
+
/** RFC 8707 resource sealed at authorize time (BR-39l Lot 2); carried authorize → consent → code. */
|
|
13
|
+
resource?: string | null;
|
|
12
14
|
scope: string;
|
|
13
15
|
state: string | null;
|
|
14
16
|
tenantId: string | null;
|
|
@@ -16,6 +16,12 @@ export interface OauthClientRecord {
|
|
|
16
16
|
requirePkce: boolean;
|
|
17
17
|
tenantId: string | null;
|
|
18
18
|
ownerUserId: string | null;
|
|
19
|
+
/**
|
|
20
|
+
* RFC 8707 resource-indicator allowlist for the `authorization_code` flow (BR-39l Lot 2).
|
|
21
|
+
* Additive, default-deny: an empty/absent allowlist means the client may NOT request any
|
|
22
|
+
* `resource` (any value ⇒ `invalid_target`). Mirrors `ServiceClientRecord.resourceIndicators`.
|
|
23
|
+
*/
|
|
24
|
+
resourceIndicators?: string[];
|
|
19
25
|
createdAt: Date;
|
|
20
26
|
updatedAt: Date;
|
|
21
27
|
}
|
|
@@ -30,6 +36,12 @@ export interface AuthCodePayload {
|
|
|
30
36
|
codeChallengeMethod: 'S256';
|
|
31
37
|
dpopJkt: string | null;
|
|
32
38
|
nonce: string | null;
|
|
39
|
+
/**
|
|
40
|
+
* RFC 8707 resource sealed at authorize time (BR-39l Lot 2). When present, it becomes the
|
|
41
|
+
* access-token `aud`; the token-leg `resource` (if sent) MUST equal it. Absent ⇒ default-aud
|
|
42
|
+
* (userinfo URL), byte-identical to auth-hono 0.5.0.
|
|
43
|
+
*/
|
|
44
|
+
resource?: string | null;
|
|
33
45
|
acr: string;
|
|
34
46
|
authTime: Date;
|
|
35
47
|
expiresAt: Date;
|
|
@@ -59,6 +59,9 @@ export const createOAuthTokenHandler =
|
|
|
59
59
|
return oauthJsonError(c, 400, 'invalid_grant', 'PKCE verification failed.');
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
const resourceError = validateTokenResource(c, form, codePayload);
|
|
63
|
+
if (resourceError) return resourceError;
|
|
64
|
+
|
|
62
65
|
const dpopJkt = await resolveDpopJkt(c, options, auth.client, codePayload);
|
|
63
66
|
if (dpopJkt instanceof Response) return dpopJkt;
|
|
64
67
|
|
|
@@ -117,6 +120,30 @@ const parseClientCredentials = (
|
|
|
117
120
|
};
|
|
118
121
|
};
|
|
119
122
|
|
|
123
|
+
/**
|
|
124
|
+
* RFC 8707 resource validation on the token leg of the `authorization_code` flow (BR-39l Lot 2).
|
|
125
|
+
* - C1 single-aud: more than one `resource` value ⇒ `invalid_target`.
|
|
126
|
+
* - C3 sealing: if the client re-sends `resource` on the token request (the MCP draft requires it
|
|
127
|
+
* on both legs), it MUST equal the value sealed at authorize time, else `invalid_grant`
|
|
128
|
+
* (audience downgrade/upgrade rejection). The `aud` is derived ONLY from the sealed value.
|
|
129
|
+
* No `resource` on the token leg is accepted (the sealed value still governs `aud`).
|
|
130
|
+
*/
|
|
131
|
+
const validateTokenResource = (
|
|
132
|
+
c: Context,
|
|
133
|
+
form: URLSearchParams,
|
|
134
|
+
codePayload: AuthCodePayload
|
|
135
|
+
): Response | null => {
|
|
136
|
+
const requested = form.getAll('resource').filter((value) => value.length > 0);
|
|
137
|
+
if (requested.length === 0) return null;
|
|
138
|
+
if (requested.length > 1) {
|
|
139
|
+
return oauthJsonError(c, 400, 'invalid_target', 'Only a single resource indicator is supported.');
|
|
140
|
+
}
|
|
141
|
+
if (requested[0] !== (codePayload.resource ?? null)) {
|
|
142
|
+
return oauthJsonError(c, 400, 'invalid_grant', 'resource does not match the authorization request.');
|
|
143
|
+
}
|
|
144
|
+
return null;
|
|
145
|
+
};
|
|
146
|
+
|
|
120
147
|
const resolveDpopJkt = async (
|
|
121
148
|
c: Context,
|
|
122
149
|
options: OAuthTokenHandlerOptions,
|
|
@@ -316,15 +343,30 @@ const issueTokens = async (
|
|
|
316
343
|
const idExpiresAt = options.ports.clock.addSeconds(now, idTokenTtlSeconds);
|
|
317
344
|
const scopes = codePayload.scope.split(/\s+/).filter(Boolean);
|
|
318
345
|
const cnf = dpopJkt ? { jkt: dpopJkt } : undefined;
|
|
346
|
+
|
|
347
|
+
// BR-39e: bind the tenant claim to a STILL-`approved` membership at token time (lifecycle
|
|
348
|
+
// gate). If the membership was suspended/revoked between authorize and token exchange, drop
|
|
349
|
+
// the claim so no tenant-scoped token is issued for a non-member.
|
|
350
|
+
let boundTenantId: string | null = codePayload.tenantId;
|
|
351
|
+
if (boundTenantId && options.ports.tenant) {
|
|
352
|
+
const stillApproved = await options.ports.tenant.isApprovedMember(codePayload.userId, boundTenantId);
|
|
353
|
+
if (!stillApproved) boundTenantId = null;
|
|
354
|
+
}
|
|
355
|
+
|
|
319
356
|
const jwks = createJwksService({ clock: options.ports.clock, jwksPort: options.ports.jwks });
|
|
320
357
|
const accessJti = options.ports.random.uuid();
|
|
321
|
-
|
|
358
|
+
// BR-39l Lot 2 (C3/C4): variable RFC 8707 audience. The access-token `aud` is the resource
|
|
359
|
+
// sealed at authorize time (single string), or the userinfo URL by default — byte-identical to
|
|
360
|
+
// auth-hono 0.5.0 when no `resource` was requested. The id_token `aud` (client_id) is untouched.
|
|
361
|
+
const accessAudience =
|
|
362
|
+
codePayload.resource ?? `${trimTrailingSlash(options.issuer)}/api/v1/auth/oauth/userinfo`;
|
|
322
363
|
const accessToken = await jwks.signJwt(
|
|
323
364
|
{
|
|
324
365
|
acr: codePayload.acr,
|
|
325
366
|
auth_time: toEpochSeconds(codePayload.authTime),
|
|
326
367
|
client_id: client.clientId,
|
|
327
368
|
...(cnf ? { cnf } : {}),
|
|
369
|
+
...(boundTenantId ? { tid: boundTenantId } : {}),
|
|
328
370
|
scope: codePayload.scope,
|
|
329
371
|
},
|
|
330
372
|
{
|
|
@@ -368,6 +410,7 @@ const issueTokens = async (
|
|
|
368
410
|
...(scopes.includes('email') ? { email: user.email, email_verified: user.emailVerified } : {}),
|
|
369
411
|
...(scopes.includes('profile') ? { name: user.displayName } : {}),
|
|
370
412
|
...(codePayload.nonce ? { nonce: codePayload.nonce } : {}),
|
|
413
|
+
...(boundTenantId ? { tid: boundTenantId } : {}),
|
|
371
414
|
},
|
|
372
415
|
{
|
|
373
416
|
audience: client.clientId,
|
|
@@ -17,7 +17,7 @@ export const createWellKnownRouter = (options: CreateWellKnownRouterOptions): Ho
|
|
|
17
17
|
router.get('/openid-configuration', (c) =>
|
|
18
18
|
c.json({
|
|
19
19
|
authorization_endpoint: `${issuer}${oauthPrefix}/authorize`,
|
|
20
|
-
claims_supported: ['sub', 'aud', 'iss', 'exp', 'iat', 'nonce', 'auth_time', 'acr', 'email', 'email_verified', 'name'],
|
|
20
|
+
claims_supported: ['sub', 'aud', 'iss', 'exp', 'iat', 'nonce', 'auth_time', 'acr', 'email', 'email_verified', 'name', 'tid'],
|
|
21
21
|
code_challenge_methods_supported: ['S256'],
|
|
22
22
|
dpop_signing_alg_values_supported: ['EdDSA'],
|
|
23
23
|
grant_types_supported: ['authorization_code', 'client_credentials'],
|
package/src/ports.ts
CHANGED
|
@@ -287,6 +287,19 @@ export interface AuthHonoAccountPolicyPort {
|
|
|
287
287
|
afterUserCreated?(user: AuthHonoUserRecord): Promise<void> | void;
|
|
288
288
|
}
|
|
289
289
|
|
|
290
|
+
/**
|
|
291
|
+
* BR-39e: tenancy spine. OPTIONAL — when absent, auth-hono keeps the legacy behavior of
|
|
292
|
+
* sourcing the auth-code `tenantId` from the client. When present, the tenant claim (`tid`)
|
|
293
|
+
* is derived ONLY from a VALIDATED `approved` membership (never a raw request parameter),
|
|
294
|
+
* and re-checked at token time (lifecycle gate).
|
|
295
|
+
*/
|
|
296
|
+
export interface AuthHonoTenantPort {
|
|
297
|
+
/** Tenant ids the user is an `approved` member of, for selection + claim derivation. */
|
|
298
|
+
listApprovedTenantIds(userId: string): Promise<string[]>;
|
|
299
|
+
/** True iff (userId, tenantId) is currently an `approved` membership (binding re-check). */
|
|
300
|
+
isApprovedMember(userId: string, tenantId: string): Promise<boolean>;
|
|
301
|
+
}
|
|
302
|
+
|
|
290
303
|
export interface AuthHonoPorts {
|
|
291
304
|
users: AuthHonoUserPort;
|
|
292
305
|
credentials: AuthHonoCredentialPort;
|
|
@@ -303,4 +316,6 @@ export interface AuthHonoPorts {
|
|
|
303
316
|
accountPolicy: AuthHonoAccountPolicyPort;
|
|
304
317
|
oauthStateStore: OauthStateStorePort;
|
|
305
318
|
jwks: JwksPort;
|
|
319
|
+
/** BR-39e tenancy spine (optional; legacy behavior when absent). */
|
|
320
|
+
tenant?: AuthHonoTenantPort;
|
|
306
321
|
}
|