@alteran/astro 0.6.1 → 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 +23 -0
- package/index.js +8 -0
- package/migrations/0009_oauth_session_state.sql +31 -0
- package/migrations/meta/0009_snapshot.json +749 -0
- package/migrations/meta/_journal.json +7 -0
- package/package.json +2 -1
- package/src/db/account.ts +134 -1
- package/src/db/schema.ts +31 -0
- package/src/handlers/root.ts +1 -1
- package/src/lib/appview/proxy.ts +11 -8
- package/src/lib/auth.ts +34 -3
- package/src/lib/jwt.ts +4 -0
- package/src/lib/oauth/as-keys.ts +29 -0
- package/src/lib/oauth/clients.ts +453 -24
- package/src/lib/oauth/consent.ts +180 -0
- package/src/lib/oauth/dpop.ts +39 -5
- package/src/lib/oauth/resource.ts +93 -21
- package/src/lib/oauth/store.ts +64 -7
- package/src/lib/refresh-session.ts +16 -0
- package/src/lib/session-tokens.ts +33 -5
- package/src/lib/token-cleanup.ts +4 -2
- package/src/lib/util.ts +0 -1
- package/src/pages/.well-known/oauth-authorization-server.ts +16 -3
- package/src/pages/.well-known/oauth-protected-resource.ts +8 -4
- package/src/pages/oauth/authorize.ts +31 -52
- package/src/pages/oauth/consent.ts +163 -66
- package/src/pages/oauth/jwks.ts +15 -0
- package/src/pages/oauth/par.ts +34 -56
- package/src/pages/oauth/revoke.ts +75 -0
- package/src/pages/oauth/token.ts +148 -89
- package/src/pages/xrpc/[...nsid].ts +7 -6
- package/src/pages/xrpc/app.bsky.actor.getPreferences.ts +3 -4
- package/src/pages/xrpc/app.bsky.actor.putPreferences.ts +3 -4
- package/src/pages/xrpc/app.bsky.unspecced.getAgeAssuranceState.ts +3 -4
- package/src/pages/xrpc/chat.bsky.convo.getLog.ts +3 -4
- package/src/pages/xrpc/chat.bsky.convo.listConvos.ts +3 -4
- package/src/pages/xrpc/com.atproto.identity.getRecommendedDidCredentials.ts +3 -4
- package/src/pages/xrpc/com.atproto.identity.requestPlcOperationSignature.ts +3 -4
- package/src/pages/xrpc/com.atproto.identity.signPlcOperation.ts +3 -4
- package/src/pages/xrpc/com.atproto.identity.submitPlcOperation.ts +3 -4
- package/src/pages/xrpc/com.atproto.repo.listMissingBlobs.ts +3 -4
- package/src/pages/xrpc/com.atproto.server.checkAccountStatus.ts +3 -4
- package/src/pages/xrpc/com.atproto.server.deleteSession.ts +28 -9
- package/src/pages/xrpc/com.atproto.server.getSession.ts +3 -4
- package/src/worker/runtime.ts +23 -1
- package/types/env.d.ts +1 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import type { Env } from '../../env';
|
|
2
|
+
import { drizzle } from 'drizzle-orm/d1';
|
|
3
|
+
import { eq } from 'drizzle-orm';
|
|
4
|
+
import { createAccount, getAccountByIdentifier } from '../../db/account';
|
|
5
|
+
import { login_attempts } from '../../db/schema';
|
|
6
|
+
import { getRuntimeString } from '../secrets';
|
|
7
|
+
import { hashPassword, verifyPassword } from '../password';
|
|
8
|
+
|
|
9
|
+
const MAX_LOGIN_ATTEMPTS = 5;
|
|
10
|
+
const LOCKOUT_DURATION_SEC = 15 * 60;
|
|
11
|
+
|
|
12
|
+
export function redirectWithOAuthError(
|
|
13
|
+
redirectUri: string,
|
|
14
|
+
error: string,
|
|
15
|
+
state: string | undefined,
|
|
16
|
+
issuer: string,
|
|
17
|
+
description?: string,
|
|
18
|
+
): Response {
|
|
19
|
+
const out = new URL(redirectUri);
|
|
20
|
+
out.searchParams.set('error', error);
|
|
21
|
+
if (description) out.searchParams.set('error_description', description);
|
|
22
|
+
if (state) out.searchParams.set('state', state);
|
|
23
|
+
out.searchParams.set('iss', issuer);
|
|
24
|
+
return Response.redirect(out.toString(), 302);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function redirectWithCode(redirectUri: string, code: string, state: string, issuer: string): Response {
|
|
28
|
+
const out = new URL(redirectUri);
|
|
29
|
+
out.searchParams.set('code', code);
|
|
30
|
+
out.searchParams.set('state', state);
|
|
31
|
+
out.searchParams.set('iss', issuer);
|
|
32
|
+
return Response.redirect(out.toString(), 302);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function requestOrigin(request: Request): string {
|
|
36
|
+
const url = new URL(request.url);
|
|
37
|
+
return `${url.protocol}//${url.host}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function publicPdsOrigin(env: Env, request: Request): string {
|
|
41
|
+
const hostname = env.PDS_HOSTNAME?.trim();
|
|
42
|
+
if (!hostname) return requestOrigin(request);
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
const configured = hostname.includes('://') ? new URL(hostname) : new URL(`https://${hostname}`);
|
|
46
|
+
configured.pathname = '';
|
|
47
|
+
configured.search = '';
|
|
48
|
+
configured.hash = '';
|
|
49
|
+
return configured.origin;
|
|
50
|
+
} catch {
|
|
51
|
+
return requestOrigin(request);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function isSameOriginPost(request: Request): boolean {
|
|
56
|
+
const origin = requestOrigin(request);
|
|
57
|
+
const secFetchSite = request.headers.get('sec-fetch-site');
|
|
58
|
+
if (secFetchSite && secFetchSite !== 'same-origin' && secFetchSite !== 'none') {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const originHeader = request.headers.get('origin');
|
|
63
|
+
if (originHeader) return originHeader === origin;
|
|
64
|
+
|
|
65
|
+
const referer = request.headers.get('referer');
|
|
66
|
+
if (!referer) return false;
|
|
67
|
+
try {
|
|
68
|
+
const ref = new URL(referer);
|
|
69
|
+
return `${ref.protocol}//${ref.host}` === origin;
|
|
70
|
+
} catch {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function authenticateSingleUserPassword(env: Env, password: string): Promise<{ did: string; handle: string } | null> {
|
|
76
|
+
if (!password) return null;
|
|
77
|
+
const did = await getRuntimeString(env, 'PDS_DID', 'did:example:single-user');
|
|
78
|
+
const handle = await getRuntimeString(env, 'PDS_HANDLE', 'user.example');
|
|
79
|
+
if (!did || !handle) return null;
|
|
80
|
+
|
|
81
|
+
let account = await getAccountByIdentifier(env, did);
|
|
82
|
+
if (!account) account = await getAccountByIdentifier(env, handle);
|
|
83
|
+
if (!account) {
|
|
84
|
+
const fallbackPassword = await getRuntimeString(env, 'USER_PASSWORD', '');
|
|
85
|
+
if (!fallbackPassword) return null;
|
|
86
|
+
await createAccount(env, {
|
|
87
|
+
did,
|
|
88
|
+
handle,
|
|
89
|
+
passwordScrypt: await hashPassword(fallbackPassword),
|
|
90
|
+
});
|
|
91
|
+
account = await getAccountByIdentifier(env, did);
|
|
92
|
+
}
|
|
93
|
+
if (!account || account.did !== did) return null;
|
|
94
|
+
if (!(await verifyPassword(password, account.passwordScrypt))) return null;
|
|
95
|
+
return { did: account.did, handle: account.handle };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function consentClientIp(request: Request): string {
|
|
99
|
+
return request.headers.get('cf-connecting-ip') || request.headers.get('x-forwarded-for') || 'unknown';
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function checkConsentPasswordLockout(env: Env, request: Request): Promise<Response | null> {
|
|
103
|
+
const db = drizzle(env.ALTERAN_DB);
|
|
104
|
+
const now = Math.floor(Date.now() / 1000);
|
|
105
|
+
const ip = consentClientIp(request);
|
|
106
|
+
const attempt = await db.select().from(login_attempts).where(eq(login_attempts.ip, ip)).get();
|
|
107
|
+
if (attempt?.locked_until && attempt.locked_until > now) {
|
|
108
|
+
const remainingSeconds = attempt.locked_until - now;
|
|
109
|
+
return new Response(
|
|
110
|
+
JSON.stringify({
|
|
111
|
+
error: 'RateLimitExceeded',
|
|
112
|
+
message: `Account locked due to too many failed attempts. Try again in ${Math.ceil(remainingSeconds / 60)} minutes.`,
|
|
113
|
+
}),
|
|
114
|
+
{ status: 429, headers: { 'Content-Type': 'application/json' } },
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function lockoutResponse(remainingSeconds: number): Response {
|
|
121
|
+
return new Response(
|
|
122
|
+
JSON.stringify({
|
|
123
|
+
error: 'RateLimitExceeded',
|
|
124
|
+
message: `Account locked due to too many failed attempts. Try again in ${Math.ceil(remainingSeconds / 60)} minutes.`,
|
|
125
|
+
}),
|
|
126
|
+
{ status: 429, headers: { 'Content-Type': 'application/json' } },
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function reserveConsentPasswordAttempt(env: Env, request: Request): Promise<Response | null> {
|
|
131
|
+
const now = Math.floor(Date.now() / 1000);
|
|
132
|
+
const ip = consentClientIp(request);
|
|
133
|
+
const row = await env.ALTERAN_DB.prepare(`
|
|
134
|
+
INSERT INTO login_attempts (ip, attempts, locked_until, last_attempt)
|
|
135
|
+
VALUES (?, 1, NULL, ?)
|
|
136
|
+
ON CONFLICT(ip) DO UPDATE SET
|
|
137
|
+
attempts = CASE
|
|
138
|
+
WHEN locked_until IS NOT NULL AND locked_until > ? THEN attempts
|
|
139
|
+
ELSE attempts + 1
|
|
140
|
+
END,
|
|
141
|
+
locked_until = CASE
|
|
142
|
+
WHEN locked_until IS NOT NULL AND locked_until > ? THEN locked_until
|
|
143
|
+
WHEN attempts + 1 >= ? THEN ?
|
|
144
|
+
ELSE NULL
|
|
145
|
+
END,
|
|
146
|
+
last_attempt = ?
|
|
147
|
+
RETURNING attempts, locked_until
|
|
148
|
+
`).bind(ip, now, now, now, MAX_LOGIN_ATTEMPTS, now + LOCKOUT_DURATION_SEC, now)
|
|
149
|
+
.first<{ attempts: number; locked_until: number | null }>();
|
|
150
|
+
|
|
151
|
+
if (row?.locked_until && row.locked_until > now) {
|
|
152
|
+
return lockoutResponse(row.locked_until - now);
|
|
153
|
+
}
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export async function recordConsentPasswordFailure(env: Env, request: Request): Promise<void> {
|
|
158
|
+
await reserveConsentPasswordAttempt(env, request);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export async function clearConsentPasswordFailures(env: Env, request: Request): Promise<void> {
|
|
162
|
+
const db = drizzle(env.ALTERAN_DB);
|
|
163
|
+
await db.delete(login_attempts).where(eq(login_attempts.ip, consentClientIp(request))).run();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export async function loginHintMatchesSingleUser(env: Env, loginHint: string | undefined): Promise<boolean> {
|
|
167
|
+
if (!loginHint) return true;
|
|
168
|
+
const did = await getRuntimeString(env, 'PDS_DID', 'did:example:single-user');
|
|
169
|
+
const handle = await getRuntimeString(env, 'PDS_HANDLE', 'user.example');
|
|
170
|
+
return loginHint === did || loginHint === handle;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function htmlEscape(value: string): string {
|
|
174
|
+
return value
|
|
175
|
+
.replace(/&/g, '&')
|
|
176
|
+
.replace(/</g, '<')
|
|
177
|
+
.replace(/>/g, '>')
|
|
178
|
+
.replace(/"/g, '"')
|
|
179
|
+
.replace(/'/g, ''');
|
|
180
|
+
}
|
package/src/lib/oauth/dpop.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Env } from '../../env';
|
|
2
2
|
import { errorMessage } from '../errors';
|
|
3
|
-
import { getOrCreateSecret, setSecret, getSecret } from '../../db/account';
|
|
3
|
+
import { cleanupExpiredOAuthReplaySecrets, createSecretOnce, getOrCreateSecret, setSecret, getSecret } from '../../db/account';
|
|
4
4
|
import { decodeProtectedHeader, importJWK, compactVerify, type JWK as JoseJWK } from 'jose';
|
|
5
5
|
import { DpopNonceError } from './dpop-errors';
|
|
6
6
|
|
|
@@ -15,7 +15,7 @@ export type DpopVerification = {
|
|
|
15
15
|
payload: Record<string, unknown>;
|
|
16
16
|
};
|
|
17
17
|
|
|
18
|
-
function b64url(bytes: Uint8Array | ArrayBuffer): string {
|
|
18
|
+
export function b64url(bytes: Uint8Array | ArrayBuffer): string {
|
|
19
19
|
const b = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
|
|
20
20
|
let s = '';
|
|
21
21
|
for (let i = 0; i < b.length; i++) s += String.fromCharCode(b[i]);
|
|
@@ -48,7 +48,7 @@ export function setDpopNonceHeader(headers: Headers, nonce: string) {
|
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
// Compute RFC7638 JWK thumbprint for P-256 JWK
|
|
51
|
-
async function jwkThumbprint(jwk: JsonWebKey): Promise<string> {
|
|
51
|
+
export async function jwkThumbprint(jwk: JsonWebKey): Promise<string> {
|
|
52
52
|
// Per RFC7638, canonical JSON with these members in lexicographic order
|
|
53
53
|
const obj: Record<string, string> = {
|
|
54
54
|
crv: String(jwk.crv ?? ''),
|
|
@@ -61,6 +61,29 @@ async function jwkThumbprint(jwk: JsonWebKey): Promise<string> {
|
|
|
61
61
|
return b64url(digest);
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
export async function consumeDpopJti(env: Env, kind: string, jti: unknown, iat: number): Promise<void> {
|
|
65
|
+
if (typeof jti !== 'string' || jti.length < 8) {
|
|
66
|
+
throw new DpopNonceError('DPoP jti required', await getAuthzNonce(env));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const key = `oauth:dpop:jti:${kind}:${jti}`;
|
|
70
|
+
const now = Math.floor(Date.now() / 1000);
|
|
71
|
+
await cleanupExpiredOAuthReplaySecrets(env, now);
|
|
72
|
+
const inserted = await createSecretOnce(env, key, JSON.stringify({ iat, exp: now + 300 }));
|
|
73
|
+
if (!inserted) {
|
|
74
|
+
throw new DpopNonceError('DPoP proof replayed', await getAuthzNonce(env));
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function consumeDpopVerificationJti(
|
|
79
|
+
env: Env,
|
|
80
|
+
verification: DpopVerification,
|
|
81
|
+
kind = 'authz',
|
|
82
|
+
): Promise<void> {
|
|
83
|
+
const iat = verification.payload.iat;
|
|
84
|
+
await consumeDpopJti(env, kind, verification.payload.jti, typeof iat === 'number' ? iat : 0);
|
|
85
|
+
}
|
|
86
|
+
|
|
64
87
|
function urlWithoutHash(u: string): string {
|
|
65
88
|
try {
|
|
66
89
|
const url = new URL(u);
|
|
@@ -73,7 +96,11 @@ function urlWithoutHash(u: string): string {
|
|
|
73
96
|
|
|
74
97
|
// removed local DER conversion; jose handles verification
|
|
75
98
|
|
|
76
|
-
export async function verifyDpop(
|
|
99
|
+
export async function verifyDpop(
|
|
100
|
+
env: Env,
|
|
101
|
+
request: Request,
|
|
102
|
+
opts?: { requireNonce?: boolean; consumeJti?: boolean },
|
|
103
|
+
): Promise<DpopVerification> {
|
|
77
104
|
const dpop = request.headers.get('DPoP');
|
|
78
105
|
const nonce = await getAuthzNonce(env);
|
|
79
106
|
if (!dpop) {
|
|
@@ -104,6 +131,9 @@ export async function verifyDpop(env: Env, request: Request, opts?: { requireNon
|
|
|
104
131
|
if (typeof payload.iat !== 'number' || now - payload.iat > 300) {
|
|
105
132
|
throw new DpopNonceError('DPoP iat too old', nonce);
|
|
106
133
|
}
|
|
134
|
+
if (payload.iat - now > 30) {
|
|
135
|
+
throw new DpopNonceError('DPoP iat is in the future', nonce);
|
|
136
|
+
}
|
|
107
137
|
|
|
108
138
|
if (opts?.requireNonce !== false) {
|
|
109
139
|
if (!payload.nonce || payload.nonce !== nonce) {
|
|
@@ -112,7 +142,11 @@ export async function verifyDpop(env: Env, request: Request, opts?: { requireNon
|
|
|
112
142
|
}
|
|
113
143
|
|
|
114
144
|
const jkt = await jwkThumbprint(header.jwk as JsonWebKey);
|
|
115
|
-
|
|
145
|
+
const verification = { jkt, jwk: header.jwk as JsonWebKey, payload };
|
|
146
|
+
if (opts?.consumeJti !== false) {
|
|
147
|
+
await consumeDpopVerificationJti(env, verification);
|
|
148
|
+
}
|
|
149
|
+
return verification;
|
|
116
150
|
}
|
|
117
151
|
|
|
118
152
|
export function dpopErrorResponse(_env: Env, error: DpopNonceError): Response {
|
|
@@ -2,6 +2,8 @@ import type { Env } from '../../env';
|
|
|
2
2
|
import { errorCode, errorMessage } from '../errors';
|
|
3
3
|
import { verifyAccessToken } from '../session-tokens';
|
|
4
4
|
import { decodeProtectedHeader, importJWK, compactVerify, type JWK as JoseJWK } from 'jose';
|
|
5
|
+
import { cleanupExpiredOAuthReplaySecrets, createSecretOnce, getOAuthSession, getSecret, setSecret } from '../../db/account';
|
|
6
|
+
import { jwkThumbprint } from './dpop';
|
|
5
7
|
|
|
6
8
|
const NONCE_PDS_KEY = 'oauth_dpop_nonce_pds';
|
|
7
9
|
|
|
@@ -31,7 +33,6 @@ function urlWithoutHash(u: string): string {
|
|
|
31
33
|
// removed local b64urlToBytes and DER helpers; jose handles verification
|
|
32
34
|
|
|
33
35
|
async function getNonce(env: Env): Promise<string> {
|
|
34
|
-
const { getSecret, setSecret } = await import('../../db/account');
|
|
35
36
|
const now = Math.floor(Date.now() / 1000);
|
|
36
37
|
const raw = await getSecret(env, NONCE_PDS_KEY);
|
|
37
38
|
if (raw) {
|
|
@@ -47,7 +48,27 @@ async function getNonce(env: Env): Promise<string> {
|
|
|
47
48
|
return v;
|
|
48
49
|
}
|
|
49
50
|
|
|
50
|
-
|
|
51
|
+
async function consumeResourceDpopJti(env: Env, jti: unknown, iat: number, nonce: string): Promise<void> {
|
|
52
|
+
if (typeof jti !== 'string' || jti.length < 8) {
|
|
53
|
+
throw new ResourceAuthError('use_dpop_nonce', { nonce, message: 'DPoP jti required' });
|
|
54
|
+
}
|
|
55
|
+
const now = Math.floor(Date.now() / 1000);
|
|
56
|
+
const key = `oauth:dpop:jti:resource:${jti}`;
|
|
57
|
+
await cleanupExpiredOAuthReplaySecrets(env, now);
|
|
58
|
+
const inserted = await createSecretOnce(env, key, JSON.stringify({ iat, exp: now + 300 }));
|
|
59
|
+
if (!inserted) {
|
|
60
|
+
throw new ResourceAuthError('invalid_token', { message: 'DPoP proof replayed' });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export type ResourceAuthContext = {
|
|
65
|
+
did: string;
|
|
66
|
+
token: string;
|
|
67
|
+
scope?: string;
|
|
68
|
+
authType: 'bearer' | 'oauth-dpop';
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export async function verifyResourceRequest(env: Env, request: Request): Promise<ResourceAuthContext | null> {
|
|
51
72
|
const auth = request.headers.get('authorization');
|
|
52
73
|
if (!auth) return null;
|
|
53
74
|
|
|
@@ -64,8 +85,13 @@ export async function verifyResourceRequest(env: Env, request: Request): Promise
|
|
|
64
85
|
}
|
|
65
86
|
|
|
66
87
|
if (scheme === 'bearer') {
|
|
67
|
-
const payload = await verifyAccessTokenOrThrow(env, token);
|
|
68
|
-
return {
|
|
88
|
+
const payload = await verifyAccessTokenOrThrow(env, token, { allowOAuth: false });
|
|
89
|
+
return {
|
|
90
|
+
did: payload.sub as string,
|
|
91
|
+
token,
|
|
92
|
+
scope: typeof payload.scope === 'string' ? payload.scope : undefined,
|
|
93
|
+
authType: 'bearer',
|
|
94
|
+
};
|
|
69
95
|
}
|
|
70
96
|
|
|
71
97
|
return null;
|
|
@@ -89,6 +115,7 @@ async function verifyDpopAccess(env: Env, request: Request, accessToken: string)
|
|
|
89
115
|
if (payload.htm !== method || payload.htu !== url) throw new ResourceAuthError('use_dpop_nonce', { nonce });
|
|
90
116
|
const now = Math.floor(Date.now()/1000);
|
|
91
117
|
if (typeof payload.iat !== 'number' || now - payload.iat > 300) throw new ResourceAuthError('use_dpop_nonce', { nonce });
|
|
118
|
+
if (payload.iat - now > 30) throw new ResourceAuthError('use_dpop_nonce', { nonce });
|
|
92
119
|
if (!payload.nonce || payload.nonce !== nonce) throw new ResourceAuthError('use_dpop_nonce', { nonce });
|
|
93
120
|
// Verify ath binding
|
|
94
121
|
const enc = new TextEncoder();
|
|
@@ -96,16 +123,29 @@ async function verifyDpopAccess(env: Env, request: Request, accessToken: string)
|
|
|
96
123
|
const accessBuf = (() => { const b = new ArrayBuffer(accessBytes.byteLength); new Uint8Array(b).set(accessBytes); return b; })();
|
|
97
124
|
const expectedAth = await crypto.subtle.digest('SHA-256', accessBuf);
|
|
98
125
|
const expectedAthB64 = b64url(expectedAth);
|
|
99
|
-
if (payload.ath !== expectedAthB64) throw new ResourceAuthError('
|
|
126
|
+
if (payload.ath !== expectedAthB64) throw new ResourceAuthError('invalid_token', { message: 'DPoP ath mismatch' });
|
|
100
127
|
// Verify signature with JOSE
|
|
101
|
-
|
|
102
|
-
// already verified above, nothing else to do
|
|
128
|
+
await importJWK(header.jwk as JoseJWK, 'ES256');
|
|
103
129
|
|
|
104
|
-
const tokenPayload = await verifyAccessTokenOrThrow(env, accessToken);
|
|
105
|
-
|
|
130
|
+
const tokenPayload = await verifyAccessTokenOrThrow(env, accessToken, { allowOAuth: true });
|
|
131
|
+
const tokenJkt = (tokenPayload.cnf as any)?.jkt;
|
|
132
|
+
if (typeof tokenJkt !== 'string') {
|
|
133
|
+
throw new ResourceAuthError('invalid_token', { message: 'DPoP access token missing cnf.jkt' });
|
|
134
|
+
}
|
|
135
|
+
const proofJkt = await jwkThumbprint(header.jwk as JsonWebKey);
|
|
136
|
+
if (proofJkt !== tokenJkt) {
|
|
137
|
+
throw new ResourceAuthError('invalid_token', { message: 'DPoP key mismatch' });
|
|
138
|
+
}
|
|
139
|
+
await consumeResourceDpopJti(env, payload.jti, payload.iat, nonce);
|
|
140
|
+
return {
|
|
141
|
+
did: tokenPayload.sub as string,
|
|
142
|
+
token: accessToken,
|
|
143
|
+
scope: typeof tokenPayload.scope === 'string' ? tokenPayload.scope : undefined,
|
|
144
|
+
authType: 'oauth-dpop' as const,
|
|
145
|
+
};
|
|
106
146
|
}
|
|
107
147
|
|
|
108
|
-
async function verifyAccessTokenOrThrow(env: Env, token: string) {
|
|
148
|
+
async function verifyAccessTokenOrThrow(env: Env, token: string, opts: { allowOAuth?: boolean } = {}) {
|
|
109
149
|
let payloadJwt: Awaited<ReturnType<typeof verifyAccessToken>>;
|
|
110
150
|
try {
|
|
111
151
|
payloadJwt = await verifyAccessToken(env, token);
|
|
@@ -122,6 +162,30 @@ async function verifyAccessTokenOrThrow(env: Env, token: string) {
|
|
|
122
162
|
if (!payloadJwt || !payloadJwt.sub) {
|
|
123
163
|
throw new ResourceAuthError('invalid_token');
|
|
124
164
|
}
|
|
165
|
+
const isOAuthToken = !!(payloadJwt.cnf as any)?.jkt;
|
|
166
|
+
if (isOAuthToken && !opts.allowOAuth) {
|
|
167
|
+
throw new ResourceAuthError('invalid_token');
|
|
168
|
+
}
|
|
169
|
+
if (isOAuthToken) {
|
|
170
|
+
const sessionId = payloadJwt.oauth_session;
|
|
171
|
+
const accessJti = payloadJwt.jti;
|
|
172
|
+
const clientId = payloadJwt.client_id;
|
|
173
|
+
if (typeof sessionId !== 'string' || typeof accessJti !== 'string' || typeof clientId !== 'string') {
|
|
174
|
+
throw new ResourceAuthError('invalid_token');
|
|
175
|
+
}
|
|
176
|
+
const session = await getOAuthSession(env, sessionId);
|
|
177
|
+
const now = Math.floor(Date.now() / 1000);
|
|
178
|
+
if (
|
|
179
|
+
!session ||
|
|
180
|
+
session.revokedAt ||
|
|
181
|
+
session.expiresAt <= now ||
|
|
182
|
+
session.accessJti !== accessJti ||
|
|
183
|
+
session.clientId !== clientId ||
|
|
184
|
+
session.did !== payloadJwt.sub
|
|
185
|
+
) {
|
|
186
|
+
throw new ResourceAuthError('invalid_token');
|
|
187
|
+
}
|
|
188
|
+
}
|
|
125
189
|
return payloadJwt;
|
|
126
190
|
}
|
|
127
191
|
|
|
@@ -151,27 +215,35 @@ export async function verifyResourceRequestHybrid(
|
|
|
151
215
|
env: Env,
|
|
152
216
|
request: Request,
|
|
153
217
|
deps: VerifyResourceHybridDeps = defaultVerifyHybridDeps,
|
|
154
|
-
): Promise<
|
|
218
|
+
): Promise<ResourceAuthContext | null> {
|
|
155
219
|
const auth = request.headers.get('authorization');
|
|
156
220
|
if (!auth) return null;
|
|
157
221
|
|
|
158
|
-
|
|
159
|
-
if (
|
|
222
|
+
const match = auth.match(/^(\S+)\s+(.+)$/);
|
|
223
|
+
if (!match) return null;
|
|
224
|
+
|
|
225
|
+
const [, schemeRaw, tokenRaw] = match;
|
|
226
|
+
const scheme = schemeRaw?.toLowerCase();
|
|
227
|
+
const token = tokenRaw?.trim();
|
|
228
|
+
if (!scheme || !token) return null;
|
|
229
|
+
|
|
230
|
+
if (scheme === 'dpop') {
|
|
160
231
|
try {
|
|
161
232
|
const result = await verifyResourceRequest(env, request);
|
|
162
233
|
if (result) return result;
|
|
163
234
|
} catch (e) {
|
|
164
|
-
|
|
165
|
-
if (errorCode(e) === 'use_dpop_nonce') throw e;
|
|
166
|
-
// Otherwise fall through to Bearer
|
|
235
|
+
throw e;
|
|
167
236
|
}
|
|
168
237
|
}
|
|
169
238
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
239
|
+
if (scheme === 'bearer') {
|
|
240
|
+
const payloadJwt = await deps.verifyAccessToken(env, token, { allowOAuth: false });
|
|
241
|
+
return {
|
|
242
|
+
did: payloadJwt.sub as string,
|
|
243
|
+
token,
|
|
244
|
+
scope: typeof payloadJwt.scope === 'string' ? payloadJwt.scope : undefined,
|
|
245
|
+
authType: 'bearer',
|
|
246
|
+
};
|
|
175
247
|
}
|
|
176
248
|
|
|
177
249
|
return null;
|
package/src/lib/oauth/store.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { getSecret, setSecret } from '../../db/account';
|
|
|
3
3
|
|
|
4
4
|
const PAR_PREFIX = 'oauth:par:';
|
|
5
5
|
const CODE_PREFIX = 'oauth:code:';
|
|
6
|
+
const CONSENT_PREFIX = 'oauth:consent:';
|
|
6
7
|
|
|
7
8
|
export interface ParRecord {
|
|
8
9
|
client_id: string;
|
|
@@ -12,7 +13,10 @@ export interface ParRecord {
|
|
|
12
13
|
scope: string;
|
|
13
14
|
state: string;
|
|
14
15
|
login_hint?: string;
|
|
16
|
+
prompt?: string;
|
|
15
17
|
dpopJkt: string;
|
|
18
|
+
clientAuthMethod: 'none' | 'private_key_jwt';
|
|
19
|
+
clientAuthKeyId?: string | null;
|
|
16
20
|
createdAt: number;
|
|
17
21
|
expiresAt: number;
|
|
18
22
|
}
|
|
@@ -24,12 +28,21 @@ export interface CodeRecord {
|
|
|
24
28
|
code_challenge: string;
|
|
25
29
|
scope: string;
|
|
26
30
|
dpopJkt: string;
|
|
31
|
+
clientAuthMethod: 'none' | 'private_key_jwt';
|
|
32
|
+
clientAuthKeyId?: string | null;
|
|
27
33
|
did: string;
|
|
28
34
|
createdAt: number;
|
|
29
35
|
expiresAt: number;
|
|
30
36
|
used?: boolean;
|
|
31
37
|
}
|
|
32
38
|
|
|
39
|
+
export interface ConsentRecord {
|
|
40
|
+
id: string;
|
|
41
|
+
csrf: string;
|
|
42
|
+
createdAt: number;
|
|
43
|
+
expiresAt: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
33
46
|
export async function savePar(env: Env, id: string, rec: ParRecord): Promise<void> {
|
|
34
47
|
await setSecret(env, PAR_PREFIX + id, JSON.stringify(rec));
|
|
35
48
|
}
|
|
@@ -39,7 +52,20 @@ export async function loadPar(env: Env, id: string): Promise<ParRecord | null> {
|
|
|
39
52
|
if (!raw) return null;
|
|
40
53
|
try {
|
|
41
54
|
const rec = JSON.parse(raw) as ParRecord;
|
|
42
|
-
if (
|
|
55
|
+
if (
|
|
56
|
+
typeof rec.client_id !== 'string' ||
|
|
57
|
+
typeof rec.redirect_uri !== 'string' ||
|
|
58
|
+
typeof rec.code_challenge !== 'string' ||
|
|
59
|
+
typeof rec.scope !== 'string' ||
|
|
60
|
+
typeof rec.state !== 'string' ||
|
|
61
|
+
typeof rec.dpopJkt !== 'string'
|
|
62
|
+
) {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
if (rec.expiresAt && rec.expiresAt < Math.floor(Date.now() / 1000)) {
|
|
66
|
+
await deletePar(env, id);
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
43
69
|
return rec;
|
|
44
70
|
} catch {
|
|
45
71
|
return null;
|
|
@@ -51,6 +77,28 @@ export async function deletePar(env: Env, id: string): Promise<void> {
|
|
|
51
77
|
await setSecret(env, PAR_PREFIX + id, JSON.stringify({}));
|
|
52
78
|
}
|
|
53
79
|
|
|
80
|
+
export async function saveConsent(env: Env, id: string, csrf: string, expiresAt: number): Promise<void> {
|
|
81
|
+
const now = Math.floor(Date.now() / 1000);
|
|
82
|
+
await setSecret(env, CONSENT_PREFIX + id, JSON.stringify({ id, csrf, createdAt: now, expiresAt }));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function consumeConsent(env: Env, id: string, csrf: string): Promise<boolean> {
|
|
86
|
+
const key = CONSENT_PREFIX + id;
|
|
87
|
+
const raw = await getSecret(env, key);
|
|
88
|
+
if (!raw) return false;
|
|
89
|
+
try {
|
|
90
|
+
const rec = JSON.parse(raw) as ConsentRecord;
|
|
91
|
+
if (rec.expiresAt && rec.expiresAt < Math.floor(Date.now() / 1000)) return false;
|
|
92
|
+
if (rec.id !== id || rec.csrf !== csrf) return false;
|
|
93
|
+
const response = await env.ALTERAN_DB.prepare(
|
|
94
|
+
'UPDATE secret SET value = ?, updated_at = ? WHERE key = ? AND value = ?'
|
|
95
|
+
).bind(JSON.stringify({}), Math.floor(Date.now()), key, raw).run();
|
|
96
|
+
return (response.meta.changes ?? 0) === 1;
|
|
97
|
+
} catch {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
54
102
|
export async function saveCode(env: Env, code: string, rec: CodeRecord): Promise<void> {
|
|
55
103
|
await setSecret(env, CODE_PREFIX + code, JSON.stringify(rec));
|
|
56
104
|
}
|
|
@@ -68,10 +116,19 @@ export async function loadCode(env: Env, code: string): Promise<CodeRecord | nul
|
|
|
68
116
|
}
|
|
69
117
|
|
|
70
118
|
export async function consumeCode(env: Env, code: string): Promise<CodeRecord | null> {
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
if (
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
119
|
+
const key = CODE_PREFIX + code;
|
|
120
|
+
const raw = await getSecret(env, key);
|
|
121
|
+
if (!raw) return null;
|
|
122
|
+
try {
|
|
123
|
+
const rec = JSON.parse(raw) as CodeRecord;
|
|
124
|
+
if (rec.expiresAt && rec.expiresAt < Math.floor(Date.now() / 1000)) return null;
|
|
125
|
+
if (rec.used) return null;
|
|
126
|
+
const used = JSON.stringify({ ...rec, used: true });
|
|
127
|
+
const response = await env.ALTERAN_DB.prepare(
|
|
128
|
+
'UPDATE secret SET value = ?, updated_at = ? WHERE key = ? AND value = ?'
|
|
129
|
+
).bind(used, Math.floor(Date.now()), key, raw).run();
|
|
130
|
+
return (response.meta.changes ?? 0) === 1 ? rec : null;
|
|
131
|
+
} catch {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
77
134
|
}
|
|
@@ -99,6 +99,22 @@ export async function attemptRefresh({ env, token, nowSec }: AttemptInput): Prom
|
|
|
99
99
|
return failure('InvalidToken', 'Refresh token has been revoked');
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
+
if (stored.revokedAt) {
|
|
103
|
+
return failure('InvalidToken', 'Refresh token has been revoked');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (stored.tokenKind !== 'legacy') {
|
|
107
|
+
return failure('InvalidToken', 'OAuth refresh token must use /oauth/token');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (
|
|
111
|
+
typeof verification.payload.client_id === 'string' ||
|
|
112
|
+
typeof verification.payload.oauth_session === 'string' ||
|
|
113
|
+
!!(verification.payload.cnf as { jkt?: unknown } | undefined)?.jkt
|
|
114
|
+
) {
|
|
115
|
+
return failure('InvalidToken', 'OAuth refresh token must use /oauth/token');
|
|
116
|
+
}
|
|
117
|
+
|
|
102
118
|
if (stored.expiresAt <= nowSec) {
|
|
103
119
|
return failure('ExpiredToken', 'Refresh token expired');
|
|
104
120
|
}
|
|
@@ -32,19 +32,34 @@ async function getServiceDid(env: Env): Promise<string> {
|
|
|
32
32
|
return did;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
export
|
|
35
|
+
export type IssueSessionTokenOptions = {
|
|
36
|
+
jti?: string;
|
|
37
|
+
accessJti?: string;
|
|
38
|
+
scope?: string;
|
|
39
|
+
clientId?: string;
|
|
40
|
+
dpopJkt?: string;
|
|
41
|
+
oauthSessionId?: string;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export async function issueSessionTokens(env: Env, did: string, opts: IssueSessionTokenOptions = {}) {
|
|
36
45
|
const jwtKey = await getJwtKey(env);
|
|
37
46
|
const serviceDid = await getServiceDid(env);
|
|
38
47
|
const now = Math.floor(Date.now() / 1000);
|
|
39
48
|
|
|
40
49
|
const accessExp = now + ACCESS_TTL_SECONDS;
|
|
41
50
|
const accessPayload: TokenPayload = {
|
|
42
|
-
scope: 'access',
|
|
51
|
+
scope: opts.dpopJkt ? (opts.scope ?? 'atproto') : 'access',
|
|
43
52
|
aud: serviceDid,
|
|
44
53
|
sub: did,
|
|
45
54
|
iat: now,
|
|
46
55
|
exp: accessExp,
|
|
47
56
|
};
|
|
57
|
+
if (opts.dpopJkt) {
|
|
58
|
+
accessPayload.cnf = { jkt: opts.dpopJkt };
|
|
59
|
+
accessPayload.jti = opts.accessJti ?? generateTokenId();
|
|
60
|
+
if (opts.clientId) accessPayload.client_id = opts.clientId;
|
|
61
|
+
if (opts.oauthSessionId) accessPayload.oauth_session = opts.oauthSessionId;
|
|
62
|
+
}
|
|
48
63
|
const accessJwt = await signJwt(jwtKey, 'at+jwt', accessPayload);
|
|
49
64
|
|
|
50
65
|
const jti = opts.jti ?? generateTokenId();
|
|
@@ -57,20 +72,26 @@ export async function issueSessionTokens(env: Env, did: string, opts: { jti?: st
|
|
|
57
72
|
exp: refreshExp,
|
|
58
73
|
jti,
|
|
59
74
|
};
|
|
75
|
+
if (opts.dpopJkt) {
|
|
76
|
+
refreshPayload.cnf = { jkt: opts.dpopJkt };
|
|
77
|
+
if (opts.clientId) refreshPayload.client_id = opts.clientId;
|
|
78
|
+
if (opts.oauthSessionId) refreshPayload.oauth_session = opts.oauthSessionId;
|
|
79
|
+
}
|
|
60
80
|
const refreshJwt = await signJwt(jwtKey, 'refresh+jwt', refreshPayload);
|
|
61
81
|
|
|
62
82
|
return {
|
|
63
83
|
accessJwt,
|
|
64
84
|
refreshJwt,
|
|
85
|
+
accessPayload,
|
|
65
86
|
refreshPayload,
|
|
66
87
|
refreshExpiry: refreshPayload.exp,
|
|
67
88
|
} as const;
|
|
68
89
|
}
|
|
69
90
|
|
|
70
|
-
export async function verifyRefreshToken(env: Env, token: string) {
|
|
91
|
+
export async function verifyRefreshToken(env: Env, token: string, opts: { ignoreExpiration?: boolean } = {}) {
|
|
71
92
|
const key = await getJwtKey(env);
|
|
72
93
|
const serviceDid = await getServiceDid(env);
|
|
73
|
-
const { header, payload } = await decodeAndVerifyJwt(key, token, 'refresh+jwt', serviceDid);
|
|
94
|
+
const { header, payload } = await decodeAndVerifyJwt(key, token, 'refresh+jwt', serviceDid, opts);
|
|
74
95
|
if (header.typ !== 'refresh+jwt') {
|
|
75
96
|
throw new InvalidToken('Invalid token type');
|
|
76
97
|
}
|
|
@@ -131,10 +152,17 @@ async function signJwt(key: Uint8Array, typ: TokenHeader['typ'], payload: TokenP
|
|
|
131
152
|
return await signer.sign(key);
|
|
132
153
|
}
|
|
133
154
|
|
|
134
|
-
async function decodeAndVerifyJwt(
|
|
155
|
+
async function decodeAndVerifyJwt(
|
|
156
|
+
key: Uint8Array,
|
|
157
|
+
token: string,
|
|
158
|
+
expectedTyp: TokenHeader['typ'],
|
|
159
|
+
audience: string,
|
|
160
|
+
opts: { ignoreExpiration?: boolean } = {},
|
|
161
|
+
) {
|
|
135
162
|
const { payload, protectedHeader } = await jwtVerify(token, key, {
|
|
136
163
|
algorithms: ['HS256'],
|
|
137
164
|
audience,
|
|
165
|
+
...(opts.ignoreExpiration ? { currentDate: new Date(0) } : {}),
|
|
138
166
|
});
|
|
139
167
|
if (protectedHeader.typ !== expectedTyp) {
|
|
140
168
|
throw new InvalidToken('Unexpected token header');
|