@sentropic/auth-hono 0.2.1 → 0.4.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 +168 -1
- package/dist/contracts.d.ts +1 -1
- package/dist/contracts.d.ts.map +1 -1
- package/dist/contracts.js +2 -0
- package/dist/contracts.js.map +1 -1
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +16 -0
- package/dist/index.js.map +1 -1
- package/dist/oauth/authorize-handler.d.ts +13 -0
- package/dist/oauth/authorize-handler.d.ts.map +1 -0
- package/dist/oauth/authorize-handler.js +143 -0
- package/dist/oauth/authorize-handler.js.map +1 -0
- package/dist/oauth/consent-decision-handler.d.ts +11 -0
- package/dist/oauth/consent-decision-handler.d.ts.map +1 -0
- package/dist/oauth/consent-decision-handler.js +58 -0
- package/dist/oauth/consent-decision-handler.js.map +1 -0
- package/dist/oauth/crypto-utils.d.ts +3 -0
- package/dist/oauth/crypto-utils.d.ts.map +1 -0
- package/dist/oauth/crypto-utils.js +13 -0
- package/dist/oauth/crypto-utils.js.map +1 -0
- package/dist/oauth/dpop.d.ts +18 -0
- package/dist/oauth/dpop.d.ts.map +1 -0
- package/dist/oauth/dpop.js +54 -0
- package/dist/oauth/dpop.js.map +1 -0
- package/dist/oauth/http-utils.d.ts +6 -0
- package/dist/oauth/http-utils.d.ts.map +1 -0
- package/dist/oauth/http-utils.js +27 -0
- package/dist/oauth/http-utils.js.map +1 -0
- package/dist/oauth/introspect-handler.d.ts +8 -0
- package/dist/oauth/introspect-handler.d.ts.map +1 -0
- package/dist/oauth/introspect-handler.js +63 -0
- package/dist/oauth/introspect-handler.js.map +1 -0
- package/dist/oauth/jwks-service.d.ts +25 -0
- package/dist/oauth/jwks-service.d.ts.map +1 -0
- package/dist/oauth/jwks-service.js +61 -0
- package/dist/oauth/jwks-service.js.map +1 -0
- package/dist/oauth/revoke-handler.d.ts +8 -0
- package/dist/oauth/revoke-handler.d.ts.map +1 -0
- package/dist/oauth/revoke-handler.js +55 -0
- package/dist/oauth/revoke-handler.js.map +1 -0
- package/dist/oauth/router.d.ts +8 -0
- package/dist/oauth/router.d.ts.map +1 -0
- package/dist/oauth/router.js +30 -0
- package/dist/oauth/router.js.map +1 -0
- package/dist/oauth/service-auth-middleware.d.ts +30 -0
- package/dist/oauth/service-auth-middleware.d.ts.map +1 -0
- package/dist/oauth/service-auth-middleware.js +170 -0
- package/dist/oauth/service-auth-middleware.js.map +1 -0
- package/dist/oauth/session-resolver.d.ts +9 -0
- package/dist/oauth/session-resolver.d.ts.map +1 -0
- package/dist/oauth/session-resolver.js +28 -0
- package/dist/oauth/session-resolver.js.map +1 -0
- package/dist/oauth/state-codec.d.ts +25 -0
- package/dist/oauth/state-codec.d.ts.map +1 -0
- package/dist/oauth/state-codec.js +60 -0
- package/dist/oauth/state-codec.js.map +1 -0
- package/dist/oauth/state-store-types.d.ts +100 -0
- package/dist/oauth/state-store-types.d.ts.map +1 -0
- package/dist/oauth/state-store-types.js +2 -0
- package/dist/oauth/state-store-types.js.map +1 -0
- package/dist/oauth/token-handler.d.ts +12 -0
- package/dist/oauth/token-handler.d.ts.map +1 -0
- package/dist/oauth/token-handler.js +294 -0
- package/dist/oauth/token-handler.js.map +1 -0
- package/dist/oauth/userinfo-handler.d.ts +9 -0
- package/dist/oauth/userinfo-handler.d.ts.map +1 -0
- package/dist/oauth/userinfo-handler.js +93 -0
- package/dist/oauth/userinfo-handler.js.map +1 -0
- package/dist/oauth/wellknown-handler.d.ts +9 -0
- package/dist/oauth/wellknown-handler.d.ts.map +1 -0
- package/dist/oauth/wellknown-handler.js +37 -0
- package/dist/oauth/wellknown-handler.js.map +1 -0
- package/dist/ports.d.ts +4 -0
- package/dist/ports.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/contracts.ts +2 -0
- package/src/index.ts +16 -0
- package/src/oauth/authorize-handler.ts +201 -0
- package/src/oauth/consent-decision-handler.ts +93 -0
- package/src/oauth/crypto-utils.ts +14 -0
- package/src/oauth/dpop.ts +93 -0
- package/src/oauth/http-utils.ts +58 -0
- package/src/oauth/introspect-handler.ts +88 -0
- package/src/oauth/jwks-service.ts +103 -0
- package/src/oauth/revoke-handler.ts +70 -0
- package/src/oauth/router.ts +42 -0
- package/src/oauth/service-auth-middleware.ts +250 -0
- package/src/oauth/session-resolver.ts +48 -0
- package/src/oauth/state-codec.ts +98 -0
- package/src/oauth/state-store-types.ts +109 -0
- package/src/oauth/token-handler.ts +423 -0
- package/src/oauth/userinfo-handler.ts +129 -0
- package/src/oauth/wellknown-handler.ts +52 -0
- package/src/ports.ts +17 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import {
|
|
2
|
+
calculateJwkThumbprint,
|
|
3
|
+
decodeProtectedHeader,
|
|
4
|
+
importJWK,
|
|
5
|
+
jwtVerify,
|
|
6
|
+
type JWK,
|
|
7
|
+
type JWTPayload,
|
|
8
|
+
} from 'jose';
|
|
9
|
+
|
|
10
|
+
import type { AuthHonoPorts } from '../ports.js';
|
|
11
|
+
import { sha256Base64url } from './crypto-utils.js';
|
|
12
|
+
|
|
13
|
+
export interface VerifyDpopProofOptions {
|
|
14
|
+
accessToken?: string;
|
|
15
|
+
htm: string;
|
|
16
|
+
htu: string;
|
|
17
|
+
iatSkewSeconds?: number;
|
|
18
|
+
ports: AuthHonoPorts;
|
|
19
|
+
proof: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface VerifiedDpopProof {
|
|
23
|
+
jkt: string;
|
|
24
|
+
jti: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class OAuthDpopProofError extends Error {
|
|
28
|
+
constructor(message: string) {
|
|
29
|
+
super(message);
|
|
30
|
+
this.name = 'OAuthDpopProofError';
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const verifyOAuthDpopProof = async (
|
|
35
|
+
options: VerifyDpopProofOptions
|
|
36
|
+
): Promise<VerifiedDpopProof> => {
|
|
37
|
+
const header = decodeProtectedHeader(options.proof);
|
|
38
|
+
const publicJwk = header.jwk as JWK | undefined;
|
|
39
|
+
if (!publicJwk || !header.alg || header.typ !== 'dpop+jwt') {
|
|
40
|
+
throw new OAuthDpopProofError('DPoP proof header is invalid.');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const key = await importJWK(publicJwk, header.alg);
|
|
44
|
+
const { payload } = await jwtVerify(options.proof, key);
|
|
45
|
+
await validateDpopPayload(payload, options);
|
|
46
|
+
|
|
47
|
+
const expiresAt = options.ports.clock.addSeconds(
|
|
48
|
+
options.ports.clock.now(),
|
|
49
|
+
options.iatSkewSeconds ?? 60
|
|
50
|
+
);
|
|
51
|
+
const recorded = await options.ports.oauthStateStore.recordDpopJti(String(payload.jti), expiresAt);
|
|
52
|
+
if (!recorded) {
|
|
53
|
+
throw new OAuthDpopProofError('DPoP proof jti was already used.');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return {
|
|
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.');
|
|
92
|
+
}
|
|
93
|
+
};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { Context } from 'hono';
|
|
2
|
+
|
|
3
|
+
export const appendParams = (
|
|
4
|
+
target: string,
|
|
5
|
+
params: Record<string, string | null | undefined>,
|
|
6
|
+
baseUrl: string
|
|
7
|
+
): string => {
|
|
8
|
+
const url = new URL(target, baseUrl);
|
|
9
|
+
for (const [key, value] of Object.entries(params)) {
|
|
10
|
+
if (value !== null && value !== undefined) {
|
|
11
|
+
url.searchParams.set(key, value);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
return url.toString();
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const oauthJsonError = (
|
|
18
|
+
c: Context,
|
|
19
|
+
status: 400 | 401,
|
|
20
|
+
code: string,
|
|
21
|
+
message: string
|
|
22
|
+
): Response =>
|
|
23
|
+
c.json(
|
|
24
|
+
{
|
|
25
|
+
error: {
|
|
26
|
+
code,
|
|
27
|
+
message,
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
status
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
export const redirectWithOAuthError = (
|
|
34
|
+
redirectUri: string,
|
|
35
|
+
code: string,
|
|
36
|
+
state: string | null,
|
|
37
|
+
baseUrl: string
|
|
38
|
+
): Response =>
|
|
39
|
+
Response.redirect(
|
|
40
|
+
appendParams(
|
|
41
|
+
redirectUri,
|
|
42
|
+
{
|
|
43
|
+
error: code,
|
|
44
|
+
state,
|
|
45
|
+
},
|
|
46
|
+
baseUrl
|
|
47
|
+
),
|
|
48
|
+
302
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
export const redirectOrJson = (c: Context, redirectTo: string): Response => {
|
|
52
|
+
const accept = c.req.header('accept') ?? '';
|
|
53
|
+
if (accept.includes('application/json')) {
|
|
54
|
+
return c.json({ redirectTo });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return c.redirect(redirectTo, 302);
|
|
58
|
+
};
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { Context } from 'hono';
|
|
2
|
+
import type { JWTPayload } from 'jose';
|
|
3
|
+
|
|
4
|
+
import type { AuthHonoPorts } from '../ports.js';
|
|
5
|
+
import { oauthJsonError } from './http-utils.js';
|
|
6
|
+
import { createJwksService } from './jwks-service.js';
|
|
7
|
+
|
|
8
|
+
export interface OAuthIntrospectHandlerOptions {
|
|
9
|
+
issuer: string;
|
|
10
|
+
ports: AuthHonoPorts;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const createOAuthIntrospectHandler =
|
|
14
|
+
(options: OAuthIntrospectHandlerOptions) =>
|
|
15
|
+
async (c: Context): Promise<Response> => {
|
|
16
|
+
const authenticated = await authenticateIntrospectionClient(c, options.ports);
|
|
17
|
+
if (authenticated instanceof Response) return authenticated;
|
|
18
|
+
|
|
19
|
+
const form = new URLSearchParams(await c.req.text());
|
|
20
|
+
const token = form.get('token');
|
|
21
|
+
if (!token) return c.json({ active: false });
|
|
22
|
+
|
|
23
|
+
const payload = await verifyToken(options, token);
|
|
24
|
+
if (!payload?.jti) return c.json({ active: false });
|
|
25
|
+
|
|
26
|
+
const meta = await options.ports.oauthStateStore.findTokenMeta(payload.jti);
|
|
27
|
+
if (
|
|
28
|
+
!meta ||
|
|
29
|
+
meta.expiresAt <= options.ports.clock.now() ||
|
|
30
|
+
(await options.ports.oauthStateStore.isTokenRevoked(payload.jti))
|
|
31
|
+
) {
|
|
32
|
+
return c.json({ active: false });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return c.json({
|
|
36
|
+
active: true,
|
|
37
|
+
aud: meta.audience,
|
|
38
|
+
client_id: meta.clientId,
|
|
39
|
+
...(meta.dpopJkt ? { cnf: { jkt: meta.dpopJkt } } : {}),
|
|
40
|
+
exp: toEpochSeconds(meta.expiresAt),
|
|
41
|
+
iat: typeof payload.iat === 'number' ? payload.iat : undefined,
|
|
42
|
+
jti: meta.jti,
|
|
43
|
+
scope: meta.scope,
|
|
44
|
+
sub: meta.userId,
|
|
45
|
+
token_type: meta.tokenType,
|
|
46
|
+
});
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const authenticateIntrospectionClient = async (
|
|
50
|
+
c: Context,
|
|
51
|
+
ports: AuthHonoPorts
|
|
52
|
+
): Promise<true | Response> => {
|
|
53
|
+
const authorization = c.req.header('authorization');
|
|
54
|
+
if (!authorization?.startsWith('Basic ')) {
|
|
55
|
+
return oauthJsonError(c, 401, 'invalid_client', 'Client authentication is required.');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const decoded = atob(authorization.slice('Basic '.length));
|
|
59
|
+
const separator = decoded.indexOf(':');
|
|
60
|
+
const clientId = separator >= 0 ? decoded.slice(0, separator) : decoded;
|
|
61
|
+
const secret = separator >= 0 ? decoded.slice(separator + 1) : '';
|
|
62
|
+
const client = await ports.oauthStateStore.findClient(clientId);
|
|
63
|
+
if (!client?.clientSecretHash || (await ports.tokens.hashSecret(secret)) !== client.clientSecretHash) {
|
|
64
|
+
return oauthJsonError(c, 401, 'invalid_client', 'Client authentication failed.');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return true;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const verifyToken = async (
|
|
71
|
+
options: OAuthIntrospectHandlerOptions,
|
|
72
|
+
token: string
|
|
73
|
+
): Promise<JWTPayload | null> => {
|
|
74
|
+
try {
|
|
75
|
+
const jwks = createJwksService({ clock: options.ports.clock, jwksPort: options.ports.jwks });
|
|
76
|
+
const result = await jwks.verifyJwt(token, {
|
|
77
|
+
currentDate: options.ports.clock.now(),
|
|
78
|
+
issuer: trimTrailingSlash(options.issuer),
|
|
79
|
+
});
|
|
80
|
+
return result.payload;
|
|
81
|
+
} catch {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const toEpochSeconds = (date: Date): number => Math.floor(date.getTime() / 1000);
|
|
87
|
+
|
|
88
|
+
const trimTrailingSlash = (value: string): string => value.replace(/\/+$/u, '');
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import {
|
|
2
|
+
decodeProtectedHeader,
|
|
3
|
+
importJWK,
|
|
4
|
+
jwtVerify,
|
|
5
|
+
SignJWT,
|
|
6
|
+
type JWTVerifyOptions,
|
|
7
|
+
type JWTVerifyResult,
|
|
8
|
+
type JWTPayload,
|
|
9
|
+
} from 'jose';
|
|
10
|
+
|
|
11
|
+
import type { AuthHonoClockPort } from '../ports.js';
|
|
12
|
+
import type { JwksPort } from './state-store-types.js';
|
|
13
|
+
|
|
14
|
+
export interface CreateJwksServiceOptions {
|
|
15
|
+
clock: AuthHonoClockPort;
|
|
16
|
+
jwksPort: JwksPort;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface JwksSignOptions {
|
|
20
|
+
audience?: string | string[];
|
|
21
|
+
expiresAt?: Date;
|
|
22
|
+
issuer?: string;
|
|
23
|
+
jti?: string;
|
|
24
|
+
subject?: string;
|
|
25
|
+
type?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface PublicJwks {
|
|
29
|
+
keys: Array<Record<string, unknown>>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface JwksService {
|
|
33
|
+
getPublicJwks(): Promise<PublicJwks>;
|
|
34
|
+
signJwt(payload: JWTPayload, options?: JwksSignOptions): Promise<string>;
|
|
35
|
+
verifyJwt<T extends JWTPayload = JWTPayload>(
|
|
36
|
+
jwt: string,
|
|
37
|
+
options?: JWTVerifyOptions
|
|
38
|
+
): Promise<JWTVerifyResult<T>>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const createJwksService = ({ clock, jwksPort }: CreateJwksServiceOptions): JwksService => ({
|
|
42
|
+
async getPublicJwks() {
|
|
43
|
+
const keys = await jwksPort.listPublicKeys();
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
keys: keys.map((key) => {
|
|
47
|
+
const { d: _privateExponent, ...publicJwk } = key.publicJwk;
|
|
48
|
+
return {
|
|
49
|
+
...publicJwk,
|
|
50
|
+
alg: key.alg,
|
|
51
|
+
crv: key.crv,
|
|
52
|
+
kid: key.kid,
|
|
53
|
+
kty: publicJwk.kty,
|
|
54
|
+
status: key.active ? 'active' : 'rotated',
|
|
55
|
+
use: 'sig',
|
|
56
|
+
};
|
|
57
|
+
}),
|
|
58
|
+
};
|
|
59
|
+
},
|
|
60
|
+
|
|
61
|
+
async signJwt(payload, options = {}) {
|
|
62
|
+
const activeKey = await jwksPort.getActiveKey();
|
|
63
|
+
if (!activeKey) {
|
|
64
|
+
throw new Error('No active JWKS signing key is configured.');
|
|
65
|
+
}
|
|
66
|
+
if (!activeKey.privateKey) {
|
|
67
|
+
throw new Error(`Active JWKS signing key ${activeKey.kid} has no private key material.`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
let jwt = new SignJWT(payload).setProtectedHeader({
|
|
71
|
+
alg: activeKey.alg,
|
|
72
|
+
kid: activeKey.kid,
|
|
73
|
+
...(options.type ? { typ: options.type } : {}),
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
jwt = jwt.setIssuedAt(toEpochSeconds(clock.now()));
|
|
77
|
+
if (options.audience) jwt = jwt.setAudience(options.audience);
|
|
78
|
+
if (options.expiresAt) jwt = jwt.setExpirationTime(toEpochSeconds(options.expiresAt));
|
|
79
|
+
if (options.issuer) jwt = jwt.setIssuer(options.issuer);
|
|
80
|
+
if (options.jti) jwt = jwt.setJti(options.jti);
|
|
81
|
+
if (options.subject) jwt = jwt.setSubject(options.subject);
|
|
82
|
+
|
|
83
|
+
return jwt.sign(activeKey.privateKey);
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
async verifyJwt(jwt, options = {}) {
|
|
87
|
+
const protectedHeader = decodeProtectedHeader(jwt);
|
|
88
|
+
const kid = protectedHeader.kid;
|
|
89
|
+
if (!kid) {
|
|
90
|
+
throw new Error('JWT protected header is missing kid.');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const key = await jwksPort.findKeyByKid(kid);
|
|
94
|
+
if (!key) {
|
|
95
|
+
throw new Error(`Unknown JWKS kid: ${kid}`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const publicKey = await importJWK(key.publicJwk, key.alg);
|
|
99
|
+
return jwtVerify(jwt, publicKey, options);
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const toEpochSeconds = (date: Date): number => Math.floor(date.getTime() / 1000);
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { Context } from 'hono';
|
|
2
|
+
import { decodeJwt } from 'jose';
|
|
3
|
+
|
|
4
|
+
import type { AuthHonoPorts } from '../ports.js';
|
|
5
|
+
import { OAuthDpopProofError, verifyOAuthDpopProof } from './dpop.js';
|
|
6
|
+
import { oauthJsonError } from './http-utils.js';
|
|
7
|
+
|
|
8
|
+
export interface OAuthRevokeHandlerOptions {
|
|
9
|
+
dpopIatSkewSeconds?: number;
|
|
10
|
+
ports: AuthHonoPorts;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const createOAuthRevokeHandler =
|
|
14
|
+
(options: OAuthRevokeHandlerOptions) =>
|
|
15
|
+
async (c: Context): Promise<Response> => {
|
|
16
|
+
const form = new URLSearchParams(await c.req.text());
|
|
17
|
+
const token = form.get('token');
|
|
18
|
+
if (!token) return oauthJsonError(c, 400, 'invalid_request', 'token is required.');
|
|
19
|
+
|
|
20
|
+
const jti = decodeTokenJti(token);
|
|
21
|
+
if (!jti) return c.json({ success: true });
|
|
22
|
+
|
|
23
|
+
const meta = await options.ports.oauthStateStore.findTokenMeta(jti);
|
|
24
|
+
if (meta?.dpopJkt) {
|
|
25
|
+
const dpop = await validateRevokeDpop(c, options, token, meta.dpopJkt);
|
|
26
|
+
if (dpop instanceof Response) return dpop;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
await options.ports.oauthStateStore.revokeToken(jti);
|
|
30
|
+
return c.json({ success: true });
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const validateRevokeDpop = async (
|
|
34
|
+
c: Context,
|
|
35
|
+
options: OAuthRevokeHandlerOptions,
|
|
36
|
+
accessToken: string,
|
|
37
|
+
expectedJkt: string
|
|
38
|
+
): Promise<null | Response> => {
|
|
39
|
+
const proof = c.req.header('dpop');
|
|
40
|
+
if (!proof) return oauthJsonError(c, 400, 'invalid_dpop_proof', 'DPoP proof is required.');
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const verified = await verifyOAuthDpopProof({
|
|
44
|
+
accessToken,
|
|
45
|
+
htm: 'POST',
|
|
46
|
+
htu: c.req.url,
|
|
47
|
+
iatSkewSeconds: options.dpopIatSkewSeconds,
|
|
48
|
+
ports: options.ports,
|
|
49
|
+
proof,
|
|
50
|
+
});
|
|
51
|
+
if (verified.jkt !== expectedJkt) {
|
|
52
|
+
return oauthJsonError(c, 400, 'invalid_dpop_proof', 'DPoP proof key does not match the token.');
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
} catch (error) {
|
|
56
|
+
if (error instanceof OAuthDpopProofError) {
|
|
57
|
+
return oauthJsonError(c, 400, 'invalid_dpop_proof', error.message);
|
|
58
|
+
}
|
|
59
|
+
throw error;
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const decodeTokenJti = (token: string): string | null => {
|
|
64
|
+
try {
|
|
65
|
+
const payload = decodeJwt(token);
|
|
66
|
+
return typeof payload.jti === 'string' ? payload.jti : null;
|
|
67
|
+
} catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
|
|
3
|
+
import { createOAuthAuthorizeHandler, type OAuthAuthorizeHandlerOptions } from './authorize-handler.js';
|
|
4
|
+
import {
|
|
5
|
+
createOAuthConsentDecisionHandler,
|
|
6
|
+
createOAuthConsentDetailsHandler,
|
|
7
|
+
} from './consent-decision-handler.js';
|
|
8
|
+
import { createOAuthIntrospectHandler } from './introspect-handler.js';
|
|
9
|
+
import { createOAuthRevokeHandler } from './revoke-handler.js';
|
|
10
|
+
import { createOAuthTokenHandler } from './token-handler.js';
|
|
11
|
+
import { createOAuthUserInfoHandler } from './userinfo-handler.js';
|
|
12
|
+
|
|
13
|
+
export interface CreateOAuthRouterOptions extends OAuthAuthorizeHandlerOptions {
|
|
14
|
+
authorizationCodeTtlSeconds?: number;
|
|
15
|
+
routePrefix?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const createOAuthRouter = (options: CreateOAuthRouterOptions): Hono => {
|
|
19
|
+
const router = new Hono();
|
|
20
|
+
const prefix = normalizeRoutePrefix(options.routePrefix ?? '/oauth');
|
|
21
|
+
|
|
22
|
+
router.get(joinRoutePath(prefix, '/authorize'), createOAuthAuthorizeHandler(options));
|
|
23
|
+
router.get(joinRoutePath(prefix, '/consent'), createOAuthConsentDetailsHandler(options));
|
|
24
|
+
router.post(joinRoutePath(prefix, '/consent/decision'), createOAuthConsentDecisionHandler(options));
|
|
25
|
+
router.post(joinRoutePath(prefix, '/token'), createOAuthTokenHandler(options));
|
|
26
|
+
router.get(joinRoutePath(prefix, '/userinfo'), createOAuthUserInfoHandler(options));
|
|
27
|
+
router.post(joinRoutePath(prefix, '/userinfo'), createOAuthUserInfoHandler(options));
|
|
28
|
+
router.post(joinRoutePath(prefix, '/revoke'), createOAuthRevokeHandler(options));
|
|
29
|
+
router.post(joinRoutePath(prefix, '/introspect'), createOAuthIntrospectHandler(options));
|
|
30
|
+
|
|
31
|
+
return router;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const normalizeRoutePrefix = (prefix: string): string => {
|
|
35
|
+
if (!prefix || prefix === '/') return '';
|
|
36
|
+
return `/${prefix.replace(/^\/+|\/+$/g, '')}`;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const joinRoutePath = (prefix: string, path: string): string => {
|
|
40
|
+
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
|
41
|
+
return `${prefix}${normalizedPath}`;
|
|
42
|
+
};
|