@sentropic/auth-hono 0.4.0 → 0.7.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/README.md +34 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/oauth/authorize-handler.d.ts +1 -0
- package/dist/oauth/authorize-handler.d.ts.map +1 -1
- package/dist/oauth/authorize-handler.js +85 -7
- 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 +7 -18
- 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/issue-authorized-code.d.ts +15 -0
- package/dist/oauth/issue-authorized-code.d.ts.map +1 -0
- package/dist/oauth/issue-authorized-code.js +29 -0
- package/dist/oauth/issue-authorized-code.js.map +1 -0
- 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 +32 -0
- package/dist/ports.d.ts.map +1 -1
- package/package.json +4 -1
- package/src/index.ts +11 -0
- package/src/oauth/authorize-handler.ts +101 -8
- package/src/oauth/consent-decision-handler.ts +11 -25
- package/src/oauth/dpop.ts +30 -67
- package/src/oauth/issue-authorized-code.ts +50 -0
- 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 +35 -0
|
@@ -4,9 +4,11 @@ import type { AuthHonoPorts } from '../ports.js';
|
|
|
4
4
|
import type { OauthClientRecord } from './state-store-types.js';
|
|
5
5
|
import type { OAuthContinuationCodec, OAuthContinuationState } from './state-codec.js';
|
|
6
6
|
import { appendParams, oauthJsonError, redirectWithOAuthError } from './http-utils.js';
|
|
7
|
+
import { issueAuthorizedCode } from './issue-authorized-code.js';
|
|
7
8
|
import { resolveOAuthAcr, resolveOAuthSession } from './session-resolver.js';
|
|
8
9
|
|
|
9
10
|
export interface OAuthAuthorizeHandlerOptions {
|
|
11
|
+
authorizationCodeTtlSeconds?: number;
|
|
10
12
|
consentUrl: string;
|
|
11
13
|
issuer: string;
|
|
12
14
|
loginUrl: string;
|
|
@@ -21,6 +23,7 @@ interface ValidatedAuthorizeRequest {
|
|
|
21
23
|
dpopJkt: string | null;
|
|
22
24
|
nonce: string | null;
|
|
23
25
|
redirectUri: string;
|
|
26
|
+
resource: string | null;
|
|
24
27
|
scope: string;
|
|
25
28
|
state: string | null;
|
|
26
29
|
}
|
|
@@ -48,19 +51,56 @@ export const createOAuthAuthorizeHandler =
|
|
|
48
51
|
return c.redirect(appendParams(options.loginUrl, { continue: continuation }, c.req.url), 302);
|
|
49
52
|
}
|
|
50
53
|
|
|
51
|
-
|
|
52
|
-
return redirectWithOAuthError(validation.redirectUri, 'consent_required', validation.state, c.req.url);
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
const sealedState = await sealContinuation(c, options, validation, {
|
|
54
|
+
const consentState: Pick<OAuthContinuationState, 'acr' | 'authTime' | 'userId'> = {
|
|
56
55
|
acr: resolveOAuthAcr(session.sessionRecord),
|
|
57
56
|
authTime: session.sessionRecord.createdAt.toISOString(),
|
|
58
57
|
userId: session.user.id,
|
|
59
|
-
}
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// Consent persistence (optional): skip the consent screen and issue the code directly
|
|
61
|
+
// when a stored grant for the exact (user, client) covers every requested scope.
|
|
62
|
+
// `prompt=consent` ALWAYS forces the screen; coverage is a strict set-superset check,
|
|
63
|
+
// so any requested scope absent from the grant re-shows consent (scope-escalation guard).
|
|
64
|
+
const skipConsent =
|
|
65
|
+
prompt !== 'consent' &&
|
|
66
|
+
(await hasCoveringGrant(options.ports, session.user.id, validation.client.clientId, validation.scope));
|
|
60
67
|
|
|
61
|
-
|
|
68
|
+
if (prompt === 'none') {
|
|
69
|
+
if (!skipConsent) {
|
|
70
|
+
return redirectWithOAuthError(validation.redirectUri, 'consent_required', validation.state, c.req.url);
|
|
71
|
+
}
|
|
72
|
+
} else if (!skipConsent) {
|
|
73
|
+
const sealedState = await sealContinuation(c, options, validation, consentState);
|
|
74
|
+
return c.redirect(appendParams(options.consentUrl, { state: sealedState }, c.req.url), 302);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const sealedState = await sealContinuation(c, options, validation, consentState);
|
|
78
|
+
const payload = await options.stateCodec.unseal(sealedState);
|
|
79
|
+
if (!payload) {
|
|
80
|
+
return oauthJsonError(c, 400, 'invalid_request', 'OAuth continuation is invalid.');
|
|
81
|
+
}
|
|
82
|
+
return issueAuthorizedCode(c, options, payload);
|
|
62
83
|
};
|
|
63
84
|
|
|
85
|
+
/**
|
|
86
|
+
* True iff `consentStore` is wired AND a stored grant for `(userId, clientId)` covers every
|
|
87
|
+
* requested scope (stored ⊇ requested). No store ⇒ false (legacy always-consent). The
|
|
88
|
+
* superset check is the scope-escalation invariant: a single uncovered scope forces consent.
|
|
89
|
+
*/
|
|
90
|
+
const hasCoveringGrant = async (
|
|
91
|
+
ports: AuthHonoPorts,
|
|
92
|
+
userId: string,
|
|
93
|
+
clientId: string,
|
|
94
|
+
requestedScope: string
|
|
95
|
+
): Promise<boolean> => {
|
|
96
|
+
if (!ports.consentStore) return false;
|
|
97
|
+
const grant = await ports.consentStore.getGrant(userId, clientId);
|
|
98
|
+
if (!grant) return false;
|
|
99
|
+
const granted = new Set(grant.scopes);
|
|
100
|
+
const requested = requestedScope.split(/\s+/).filter(Boolean);
|
|
101
|
+
return requested.every((scope) => granted.has(scope));
|
|
102
|
+
};
|
|
103
|
+
|
|
64
104
|
const resumeLoginContinuation = async (
|
|
65
105
|
c: Context,
|
|
66
106
|
options: OAuthAuthorizeHandlerOptions,
|
|
@@ -129,12 +169,16 @@ const validateAuthorizeRequest = async (
|
|
|
129
169
|
const scopeResult = validateScope(c.req.query('scope') ?? '', client, redirectUri, state, c.req.url);
|
|
130
170
|
if (scopeResult instanceof Response) return scopeResult;
|
|
131
171
|
|
|
172
|
+
const resourceResult = validateResource(c.req.queries('resource'), client, redirectUri, state, c.req.url);
|
|
173
|
+
if (resourceResult instanceof Response) return resourceResult;
|
|
174
|
+
|
|
132
175
|
return {
|
|
133
176
|
client,
|
|
134
177
|
codeChallenge,
|
|
135
178
|
dpopJkt: c.req.query('dpop_jkt') ?? null,
|
|
136
179
|
nonce: c.req.query('nonce') ?? null,
|
|
137
180
|
redirectUri,
|
|
181
|
+
resource: resourceResult,
|
|
138
182
|
scope: scopeResult,
|
|
139
183
|
state,
|
|
140
184
|
};
|
|
@@ -174,6 +218,34 @@ const validateScope = (
|
|
|
174
218
|
return requestedScopes.join(' ');
|
|
175
219
|
};
|
|
176
220
|
|
|
221
|
+
/**
|
|
222
|
+
* RFC 8707 resource indicator validation on the `authorization_code` flow (BR-39l Lot 2).
|
|
223
|
+
* - C1 single-aud: more than one `resource` value ⇒ `invalid_target` (no multi-audience tokens).
|
|
224
|
+
* - C2 default-deny allowlist: a requested `resource` must be in `client.resourceIndicators`,
|
|
225
|
+
* else `invalid_target`. No `resource` ⇒ `null` (default-aud = userinfo, byte-identical to 0.5.0).
|
|
226
|
+
* The validated value is sealed into the continuation and becomes the access-token `aud`.
|
|
227
|
+
*/
|
|
228
|
+
const validateResource = (
|
|
229
|
+
resources: string[] | undefined,
|
|
230
|
+
client: OauthClientRecord,
|
|
231
|
+
redirectUri: string,
|
|
232
|
+
state: string | null,
|
|
233
|
+
baseUrl: string
|
|
234
|
+
): string | null | Response => {
|
|
235
|
+
const requested = (resources ?? []).filter((value) => value.length > 0);
|
|
236
|
+
if (requested.length === 0) return null;
|
|
237
|
+
if (requested.length > 1) {
|
|
238
|
+
return redirectWithOAuthError(redirectUri, 'invalid_target', state, baseUrl);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const value = requested[0];
|
|
242
|
+
const allowlist = client.resourceIndicators ?? [];
|
|
243
|
+
if (!allowlist.includes(value)) {
|
|
244
|
+
return redirectWithOAuthError(redirectUri, 'invalid_target', state, baseUrl);
|
|
245
|
+
}
|
|
246
|
+
return value;
|
|
247
|
+
};
|
|
248
|
+
|
|
177
249
|
const sealContinuation = async (
|
|
178
250
|
c: Context,
|
|
179
251
|
options: OAuthAuthorizeHandlerOptions,
|
|
@@ -182,6 +254,26 @@ const sealContinuation = async (
|
|
|
182
254
|
): Promise<string> => {
|
|
183
255
|
const now = options.ports.clock.now();
|
|
184
256
|
const expiresAt = options.ports.clock.addSeconds(now, options.stateTtlSeconds ?? 10 * 60);
|
|
257
|
+
|
|
258
|
+
// BR-39e: derive the tenant bound to this auth code from the user's VALIDATED membership,
|
|
259
|
+
// never from the raw client/param. Legacy behavior (client tenant) when no tenancy spine is
|
|
260
|
+
// wired. An explicit `?tenant=` selection is honored ONLY if it is an approved membership.
|
|
261
|
+
let tenantId: string | null = request.client.tenantId;
|
|
262
|
+
if (options.ports.tenant) {
|
|
263
|
+
tenantId = null;
|
|
264
|
+
if (session?.userId) {
|
|
265
|
+
const approved = await options.ports.tenant.listApprovedTenantIds(session.userId);
|
|
266
|
+
const requested = c.req.query('tenant') ?? null;
|
|
267
|
+
if (requested) {
|
|
268
|
+
tenantId = approved.includes(requested) ? requested : null;
|
|
269
|
+
} else if (approved.length === 1) {
|
|
270
|
+
tenantId = approved[0];
|
|
271
|
+
}
|
|
272
|
+
// 0 or >1 approved tenants without a valid explicit selection → no tenant claim
|
|
273
|
+
// (a multi-tenant selection screen is deferred; the RP may re-request with ?tenant=).
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
185
277
|
return options.stateCodec.seal({
|
|
186
278
|
acr: session?.acr,
|
|
187
279
|
authTime: session?.authTime,
|
|
@@ -193,9 +285,10 @@ const sealContinuation = async (
|
|
|
193
285
|
expiresAt: expiresAt.toISOString(),
|
|
194
286
|
nonce: request.nonce,
|
|
195
287
|
redirectUri: request.redirectUri,
|
|
288
|
+
resource: request.resource,
|
|
196
289
|
scope: request.scope,
|
|
197
290
|
state: request.state,
|
|
198
|
-
tenantId
|
|
291
|
+
tenantId,
|
|
199
292
|
userId: session?.userId,
|
|
200
293
|
});
|
|
201
294
|
};
|
|
@@ -2,6 +2,7 @@ import type { Context } from 'hono';
|
|
|
2
2
|
|
|
3
3
|
import type { AuthHonoPorts } from '../ports.js';
|
|
4
4
|
import { appendParams, oauthJsonError, redirectOrJson } from './http-utils.js';
|
|
5
|
+
import { issueAuthorizedCode } from './issue-authorized-code.js';
|
|
5
6
|
import type { OAuthContinuationCodec, OAuthContinuationState } from './state-codec.js';
|
|
6
7
|
import { resolveOAuthSession } from './session-resolver.js';
|
|
7
8
|
|
|
@@ -46,32 +47,17 @@ export const createOAuthConsentDecisionHandler =
|
|
|
46
47
|
);
|
|
47
48
|
}
|
|
48
49
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
codeChallengeMethod: 'S256',
|
|
59
|
-
createdAt: now,
|
|
60
|
-
dpopJkt: payload.dpopJkt,
|
|
61
|
-
expiresAt: options.ports.clock.addSeconds(now, options.authorizationCodeTtlSeconds ?? 60),
|
|
62
|
-
nonce: payload.nonce,
|
|
63
|
-
redirectUri: payload.redirectUri,
|
|
64
|
-
scope: payload.scope,
|
|
65
|
-
tenantId: payload.tenantId,
|
|
66
|
-
userId: payload.userId ?? '',
|
|
67
|
-
},
|
|
68
|
-
options.authorizationCodeTtlSeconds ?? 60
|
|
69
|
-
);
|
|
50
|
+
// Persist the grant so subsequent authorize requests for a covered scope set skip consent.
|
|
51
|
+
// Approve-only: a deny never records a grant. Absent consentStore ⇒ legacy (no persistence).
|
|
52
|
+
if (options.ports.consentStore && payload.userId) {
|
|
53
|
+
await options.ports.consentStore.saveGrant(
|
|
54
|
+
payload.userId,
|
|
55
|
+
payload.clientId,
|
|
56
|
+
payload.scope.split(/\s+/).filter(Boolean)
|
|
57
|
+
);
|
|
58
|
+
}
|
|
70
59
|
|
|
71
|
-
return
|
|
72
|
-
c,
|
|
73
|
-
appendParams(payload.redirectUri, { code, state: payload.state }, c.req.url)
|
|
74
|
-
);
|
|
60
|
+
return issueAuthorizedCode(c, options, payload);
|
|
75
61
|
};
|
|
76
62
|
|
|
77
63
|
const validateConsentState = async (
|
package/src/oauth/dpop.ts
CHANGED
|
@@ -1,14 +1,10 @@
|
|
|
1
1
|
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
type JWK,
|
|
7
|
-
type JWTPayload,
|
|
8
|
-
} from 'jose';
|
|
2
|
+
DpopVerifyError,
|
|
3
|
+
verifyDpopProof,
|
|
4
|
+
type VerifiedDpop,
|
|
5
|
+
} from '@sentropic/oauth-verify';
|
|
9
6
|
|
|
10
7
|
import type { AuthHonoPorts } from '../ports.js';
|
|
11
|
-
import { sha256Base64url } from './crypto-utils.js';
|
|
12
8
|
|
|
13
9
|
export interface VerifyDpopProofOptions {
|
|
14
10
|
accessToken?: string;
|
|
@@ -19,10 +15,7 @@ export interface VerifyDpopProofOptions {
|
|
|
19
15
|
proof: string;
|
|
20
16
|
}
|
|
21
17
|
|
|
22
|
-
export
|
|
23
|
-
jkt: string;
|
|
24
|
-
jti: string;
|
|
25
|
-
}
|
|
18
|
+
export type VerifiedDpopProof = VerifiedDpop;
|
|
26
19
|
|
|
27
20
|
export class OAuthDpopProofError extends Error {
|
|
28
21
|
constructor(message: string) {
|
|
@@ -31,63 +24,33 @@ export class OAuthDpopProofError extends Error {
|
|
|
31
24
|
}
|
|
32
25
|
}
|
|
33
26
|
|
|
27
|
+
/**
|
|
28
|
+
* AS-side DPoP proof verification. Thin adapter over `@sentropic/oauth-verify`'s shared
|
|
29
|
+
* `verifyDpopProof`: it binds the IdP's clock + replay store and re-maps verification
|
|
30
|
+
* failures onto `OAuthDpopProofError` for the OAuth handlers (token/userinfo/revoke).
|
|
31
|
+
*/
|
|
34
32
|
export const verifyOAuthDpopProof = async (
|
|
35
33
|
options: VerifyDpopProofOptions
|
|
36
34
|
): Promise<VerifiedDpopProof> => {
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
jkt: await calculateJwkThumbprint(publicJwk),
|
|
58
|
-
jti: String(payload.jti),
|
|
59
|
-
};
|
|
60
|
-
};
|
|
61
|
-
|
|
62
|
-
const validateDpopPayload = async (
|
|
63
|
-
payload: JWTPayload,
|
|
64
|
-
options: VerifyDpopProofOptions
|
|
65
|
-
): Promise<void> => {
|
|
66
|
-
if (payload.htm !== options.htm.toUpperCase()) {
|
|
67
|
-
throw new OAuthDpopProofError('DPoP htm claim does not match the request method.');
|
|
68
|
-
}
|
|
69
|
-
if (payload.htu !== options.htu) {
|
|
70
|
-
throw new OAuthDpopProofError('DPoP htu claim does not match the request URL.');
|
|
71
|
-
}
|
|
72
|
-
if (!payload.jti || typeof payload.jti !== 'string') {
|
|
73
|
-
throw new OAuthDpopProofError('DPoP jti claim is required.');
|
|
74
|
-
}
|
|
75
|
-
if (typeof payload.iat !== 'number') {
|
|
76
|
-
throw new OAuthDpopProofError('DPoP iat claim is required.');
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
const nowSeconds = Math.floor(options.ports.clock.now().getTime() / 1000);
|
|
80
|
-
if (Math.abs(payload.iat - nowSeconds) > (options.iatSkewSeconds ?? 60)) {
|
|
81
|
-
throw new OAuthDpopProofError('DPoP iat claim is outside the allowed skew.');
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
if (options.accessToken) {
|
|
85
|
-
await validateAth(payload, options.accessToken);
|
|
86
|
-
}
|
|
87
|
-
};
|
|
88
|
-
|
|
89
|
-
const validateAth = async (payload: JWTPayload, accessToken: string): Promise<void> => {
|
|
90
|
-
if (payload.ath !== (await sha256Base64url(accessToken))) {
|
|
91
|
-
throw new OAuthDpopProofError('DPoP ath claim does not match the access token.');
|
|
35
|
+
const iatSkewSec = options.iatSkewSeconds ?? 60;
|
|
36
|
+
try {
|
|
37
|
+
return await verifyDpopProof({
|
|
38
|
+
accessToken: options.accessToken,
|
|
39
|
+
htm: options.htm,
|
|
40
|
+
htu: options.htu,
|
|
41
|
+
iatSkewSec,
|
|
42
|
+
now: options.ports.clock.now(),
|
|
43
|
+
proof: options.proof,
|
|
44
|
+
replay: (jti) =>
|
|
45
|
+
options.ports.oauthStateStore.recordDpopJti(
|
|
46
|
+
jti,
|
|
47
|
+
options.ports.clock.addSeconds(options.ports.clock.now(), iatSkewSec)
|
|
48
|
+
),
|
|
49
|
+
});
|
|
50
|
+
} catch (error) {
|
|
51
|
+
if (error instanceof DpopVerifyError) {
|
|
52
|
+
throw new OAuthDpopProofError(error.message);
|
|
53
|
+
}
|
|
54
|
+
throw error;
|
|
92
55
|
}
|
|
93
56
|
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { Context } from 'hono';
|
|
2
|
+
|
|
3
|
+
import type { AuthHonoPorts } from '../ports.js';
|
|
4
|
+
import { appendParams, redirectOrJson } from './http-utils.js';
|
|
5
|
+
import type { OAuthContinuationState } from './state-codec.js';
|
|
6
|
+
|
|
7
|
+
export interface IssueAuthorizedCodeOptions {
|
|
8
|
+
authorizationCodeTtlSeconds?: number;
|
|
9
|
+
ports: AuthHonoPorts;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Single source of truth for issuing an authorization code: mint a single-use code,
|
|
14
|
+
* persist its sealed payload, and redirect (or JSON) back to the RP `redirect_uri` with
|
|
15
|
+
* `code` + `state`. Called by BOTH the consent-approve path and the authorize skip-path
|
|
16
|
+
* (FL-1: never duplicate the seal / single-use-code / redirect logic).
|
|
17
|
+
*/
|
|
18
|
+
export const issueAuthorizedCode = async (
|
|
19
|
+
c: Context,
|
|
20
|
+
options: IssueAuthorizedCodeOptions,
|
|
21
|
+
payload: OAuthContinuationState
|
|
22
|
+
): Promise<Response> => {
|
|
23
|
+
const code = options.ports.random.token(32);
|
|
24
|
+
const now = options.ports.clock.now();
|
|
25
|
+
await options.ports.oauthStateStore.saveAuthCode(
|
|
26
|
+
code,
|
|
27
|
+
{
|
|
28
|
+
acr: payload.acr ?? 'urn:sentropic:loa:bearer',
|
|
29
|
+
authTime: new Date(payload.authTime ?? now.toISOString()),
|
|
30
|
+
clientId: payload.clientId,
|
|
31
|
+
codeChallenge: payload.codeChallenge,
|
|
32
|
+
codeChallengeMethod: 'S256',
|
|
33
|
+
createdAt: now,
|
|
34
|
+
dpopJkt: payload.dpopJkt,
|
|
35
|
+
expiresAt: options.ports.clock.addSeconds(now, options.authorizationCodeTtlSeconds ?? 60),
|
|
36
|
+
nonce: payload.nonce,
|
|
37
|
+
redirectUri: payload.redirectUri,
|
|
38
|
+
resource: payload.resource ?? null,
|
|
39
|
+
scope: payload.scope,
|
|
40
|
+
tenantId: payload.tenantId,
|
|
41
|
+
userId: payload.userId ?? '',
|
|
42
|
+
},
|
|
43
|
+
options.authorizationCodeTtlSeconds ?? 60
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
return redirectOrJson(
|
|
47
|
+
c,
|
|
48
|
+
appendParams(payload.redirectUri, { code, state: payload.state }, c.req.url)
|
|
49
|
+
);
|
|
50
|
+
};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
+
import { fromJwksPort } from '@sentropic/oauth-verify';
|
|
1
2
|
import {
|
|
2
3
|
decodeProtectedHeader,
|
|
3
|
-
importJWK,
|
|
4
4
|
jwtVerify,
|
|
5
5
|
SignJWT,
|
|
6
6
|
type JWTVerifyOptions,
|
|
@@ -85,17 +85,13 @@ export const createJwksService = ({ clock, jwksPort }: CreateJwksServiceOptions)
|
|
|
85
85
|
|
|
86
86
|
async verifyJwt(jwt, options = {}) {
|
|
87
87
|
const protectedHeader = decodeProtectedHeader(jwt);
|
|
88
|
-
|
|
89
|
-
if (!kid) {
|
|
88
|
+
if (!protectedHeader.kid) {
|
|
90
89
|
throw new Error('JWT protected header is missing kid.');
|
|
91
90
|
}
|
|
92
91
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
const publicKey = await importJWK(key.publicJwk, key.alg);
|
|
92
|
+
// Key resolution is shared with @sentropic/oauth-verify (single verify core); the
|
|
93
|
+
// AS-side claim assertions (iss/aud/currentDate) stay here via jose JWTVerifyOptions.
|
|
94
|
+
const publicKey = await fromJwksPort(jwksPort).resolveKey(protectedHeader);
|
|
99
95
|
return jwtVerify(jwt, publicKey, options);
|
|
100
96
|
},
|
|
101
97
|
});
|
|
@@ -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;
|