@alteran/astro 0.7.6 → 0.8.1
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 +25 -25
- package/migrations/0010_eminent_klaw.sql +37 -0
- package/migrations/0011_chief_darwin.sql +31 -0
- package/migrations/0012_backfill_blob_usage.sql +39 -0
- package/migrations/meta/0010_snapshot.json +790 -0
- package/migrations/meta/0011_snapshot.json +813 -0
- package/migrations/meta/_journal.json +22 -1
- package/package.json +24 -41
- package/src/db/blob.ts +323 -0
- package/src/db/dal.ts +224 -78
- package/src/db/repo.ts +205 -25
- package/src/db/schema.ts +14 -5
- package/src/handlers/debug.ts +4 -3
- package/src/lib/appview/auth-policy.ts +7 -24
- package/src/lib/appview/proxy.ts +56 -23
- package/src/lib/appview/types.ts +1 -6
- package/src/lib/auth-scope.ts +399 -0
- package/src/lib/auth.ts +40 -39
- package/src/lib/commit.ts +37 -15
- package/src/lib/did-document.ts +4 -5
- package/src/lib/jwt.ts +3 -1
- package/src/lib/mime.ts +9 -0
- package/src/lib/oauth/observability.ts +53 -12
- package/src/lib/oauth/resource.ts +49 -0
- package/src/lib/preference-policy.ts +45 -0
- package/src/lib/preferences.ts +0 -4
- package/src/lib/public-host.ts +127 -0
- package/src/lib/ratelimit.ts +37 -12
- package/src/lib/relay.ts +7 -27
- package/src/lib/repo-write-blob-constraints.ts +141 -0
- package/src/lib/repo-write-data.ts +195 -0
- package/src/lib/repo-write-error.ts +46 -0
- package/src/lib/repo-write-validation.ts +463 -0
- package/src/lib/session-tokens.ts +22 -5
- package/src/lib/unsupported-routes.ts +32 -0
- package/src/lib/util.ts +57 -2
- package/src/pages/.well-known/atproto-did.ts +15 -3
- package/src/pages/.well-known/did.json.ts +13 -7
- package/src/pages/debug/db/bootstrap.ts +4 -3
- package/src/pages/debug/gc/blobs.ts +11 -8
- package/src/pages/debug/record.ts +11 -0
- package/src/pages/oauth/token.ts +78 -33
- package/src/pages/xrpc/[...nsid].ts +17 -9
- package/src/pages/xrpc/app.bsky.actor.getPreferences.ts +9 -3
- package/src/pages/xrpc/app.bsky.actor.putPreferences.ts +17 -4
- package/src/pages/xrpc/app.bsky.unspecced.getAgeAssuranceState.ts +4 -2
- package/src/pages/xrpc/chat.bsky.convo.getLog.ts +4 -2
- package/src/pages/xrpc/chat.bsky.convo.listConvos.ts +4 -2
- package/src/pages/xrpc/com.atproto.identity.getRecommendedDidCredentials.ts +10 -6
- package/src/pages/xrpc/com.atproto.identity.requestPlcOperationSignature.ts +4 -3
- package/src/pages/xrpc/com.atproto.identity.resolveHandle.ts +13 -5
- package/src/pages/xrpc/com.atproto.identity.signPlcOperation.ts +4 -2
- package/src/pages/xrpc/com.atproto.identity.submitPlcOperation.ts +4 -2
- package/src/pages/xrpc/com.atproto.identity.updateHandle.ts +12 -36
- package/src/pages/xrpc/com.atproto.repo.applyWrites.ts +90 -139
- package/src/pages/xrpc/com.atproto.repo.createRecord.ts +74 -47
- package/src/pages/xrpc/com.atproto.repo.deleteRecord.ts +119 -46
- package/src/pages/xrpc/com.atproto.repo.describeRepo.ts +21 -20
- package/src/pages/xrpc/com.atproto.repo.getRecord.ts +6 -1
- package/src/pages/xrpc/com.atproto.repo.listMissingBlobs.ts +4 -2
- package/src/pages/xrpc/com.atproto.repo.putRecord.ts +84 -47
- package/src/pages/xrpc/com.atproto.repo.uploadBlob.ts +199 -78
- package/src/pages/xrpc/com.atproto.server.checkAccountStatus.ts +4 -2
- package/src/pages/xrpc/com.atproto.server.getServiceAuth.ts +88 -21
- package/src/pages/xrpc/com.atproto.server.getSession.ts +3 -13
- package/src/pages/xrpc/com.atproto.sync.getBlob.ts +92 -74
- package/src/pages/xrpc/com.atproto.sync.listBlobs.ts +45 -23
- package/src/services/car.ts +13 -0
- package/src/services/repo/apply-prepared-writes.ts +185 -0
- package/src/services/repo/blob-refs.ts +48 -0
- package/src/services/repo/blockstore-ops.ts +59 -17
- package/src/services/repo/list-blobs.ts +43 -0
- package/src/services/repo-manager.ts +221 -78
- package/src/worker/runtime.ts +1 -1
- package/src/worker/sequencer/upgrade.ts +4 -1
package/src/lib/auth.ts
CHANGED
|
@@ -3,10 +3,21 @@ import { AuthTokenExpiredError, expiredToken } from './auth-errors';
|
|
|
3
3
|
import { verifyJwt, type JwtClaims } from './jwt';
|
|
4
4
|
import { handleResourceAuthError, verifyResourceRequestHybrid } from './oauth/resource';
|
|
5
5
|
import { bearerToken } from './util';
|
|
6
|
+
import { getAccountState } from '../db/dal';
|
|
7
|
+
import {
|
|
8
|
+
bearerAccessContext,
|
|
9
|
+
canAccessFullAccount,
|
|
10
|
+
isBearerAccessScope,
|
|
11
|
+
oauthAccessContext,
|
|
12
|
+
withAccountStatus,
|
|
13
|
+
type AuthAccessContext,
|
|
14
|
+
type AuthAccountStatus,
|
|
15
|
+
} from './auth-scope';
|
|
6
16
|
|
|
7
17
|
export interface AuthContext {
|
|
8
18
|
token: string;
|
|
9
19
|
claims: JwtClaims;
|
|
20
|
+
access: AuthAccessContext;
|
|
10
21
|
}
|
|
11
22
|
|
|
12
23
|
function authScheme(request: Request): string | null {
|
|
@@ -16,63 +27,31 @@ function authScheme(request: Request): string | null {
|
|
|
16
27
|
}
|
|
17
28
|
|
|
18
29
|
export async function isAuthorized(request: Request, env: Env): Promise<boolean> {
|
|
19
|
-
const auth = request.headers.get('authorization');
|
|
20
|
-
|
|
21
|
-
console.error('=== AUTH DEBUG START ===');
|
|
22
|
-
console.error('URL:', request.url);
|
|
23
|
-
console.error('Has Auth Header:', !!auth);
|
|
24
|
-
console.error('Auth Prefix:', auth?.substring(0, 30));
|
|
25
|
-
console.error('=== AUTH DEBUG END ===');
|
|
26
|
-
|
|
27
|
-
if (authScheme(request) === 'dpop') {
|
|
28
|
-
const result = await verifyResourceRequestHybrid(env, request);
|
|
29
|
-
return !!result;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
const token = bearerToken(request);
|
|
33
|
-
if (!token) {
|
|
34
|
-
console.error('RESULT: No Bearer token found');
|
|
35
|
-
return false;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
console.error('Token Length:', token.length);
|
|
39
|
-
console.error('Token Prefix:', token.substring(0, 30));
|
|
40
|
-
|
|
41
|
-
// Prefer JWT
|
|
42
|
-
let ver;
|
|
43
30
|
try {
|
|
44
|
-
|
|
31
|
+
const auth = await authenticateRequest(request, env);
|
|
32
|
+
if (auth) return canAccessFullAccount(auth.access);
|
|
45
33
|
} catch (error) {
|
|
46
34
|
if (error instanceof AuthTokenExpiredError) {
|
|
47
35
|
throw error;
|
|
48
36
|
}
|
|
49
|
-
|
|
50
|
-
return false;
|
|
37
|
+
throw error;
|
|
51
38
|
}
|
|
52
39
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
if (ver && ver.valid && ver.payload.t === 'access') {
|
|
58
|
-
console.error('RESULT: JWT Success');
|
|
59
|
-
return true;
|
|
40
|
+
const token = bearerToken(request);
|
|
41
|
+
if (!token) {
|
|
42
|
+
return false;
|
|
60
43
|
}
|
|
61
44
|
|
|
62
45
|
// Back-compat local escape hatch if explicitly enabled
|
|
63
46
|
const allowDev = (env as any).PDS_ALLOW_DEV_TOKEN === '1';
|
|
64
|
-
console.error('Allow Dev Token:', allowDev);
|
|
65
47
|
|
|
66
48
|
if (allowDev && token === 'dev-access-token') {
|
|
67
|
-
console.error('RESULT: Dev token accepted');
|
|
68
49
|
return true;
|
|
69
50
|
}
|
|
70
51
|
if (allowDev && env.USER_PASSWORD && token === env.USER_PASSWORD) {
|
|
71
|
-
console.error('RESULT: User password accepted');
|
|
72
52
|
return true;
|
|
73
53
|
}
|
|
74
54
|
|
|
75
|
-
console.error('RESULT: Unauthorized');
|
|
76
55
|
return false;
|
|
77
56
|
}
|
|
78
57
|
|
|
@@ -91,6 +70,11 @@ export async function authenticateRequest(request: Request, env: Env): Promise<A
|
|
|
91
70
|
if (authScheme(request) === 'dpop') {
|
|
92
71
|
const result = await verifyResourceRequestHybrid(env, request);
|
|
93
72
|
if (!result) return null;
|
|
73
|
+
const access = await withResolvedAccountStatus(
|
|
74
|
+
env,
|
|
75
|
+
result.did,
|
|
76
|
+
oauthAccessContext(result.scope ?? 'atproto'),
|
|
77
|
+
);
|
|
94
78
|
return {
|
|
95
79
|
token: result.token,
|
|
96
80
|
claims: {
|
|
@@ -98,6 +82,7 @@ export async function authenticateRequest(request: Request, env: Env): Promise<A
|
|
|
98
82
|
scope: result.scope,
|
|
99
83
|
t: 'access',
|
|
100
84
|
} as JwtClaims,
|
|
85
|
+
access,
|
|
101
86
|
};
|
|
102
87
|
}
|
|
103
88
|
|
|
@@ -116,7 +101,23 @@ export async function authenticateRequest(request: Request, env: Env): Promise<A
|
|
|
116
101
|
if (!ver || !ver.valid) return null;
|
|
117
102
|
const claims = ver.payload as JwtClaims;
|
|
118
103
|
if (claims.t !== 'access') return null;
|
|
119
|
-
|
|
104
|
+
if (!isBearerAccessScope(claims.scope)) return null;
|
|
105
|
+
const access = await withResolvedAccountStatus(
|
|
106
|
+
env,
|
|
107
|
+
claims.sub,
|
|
108
|
+
bearerAccessContext(claims.scope),
|
|
109
|
+
);
|
|
110
|
+
return { token, claims, access };
|
|
120
111
|
}
|
|
121
112
|
|
|
122
113
|
export { AuthTokenExpiredError, expiredToken } from './auth-errors';
|
|
114
|
+
|
|
115
|
+
async function withResolvedAccountStatus(
|
|
116
|
+
env: Env,
|
|
117
|
+
did: string,
|
|
118
|
+
access: AuthAccessContext,
|
|
119
|
+
): Promise<AuthAccessContext> {
|
|
120
|
+
const state = await getAccountState(env, did);
|
|
121
|
+
const accountStatus: AuthAccountStatus = state?.tag ?? 'active';
|
|
122
|
+
return withAccountStatus(access, accountStatus);
|
|
123
|
+
}
|
package/src/lib/commit.ts
CHANGED
|
@@ -123,30 +123,52 @@ export function deserializeCommit(bytes: Uint8Array): SignedCommit {
|
|
|
123
123
|
return dagCbor.decode(bytes) as SignedCommit;
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
*/
|
|
130
|
-
export function generateTid(): string {
|
|
131
|
-
const now = Date.now();
|
|
132
|
-
const timestamp = now * 1000; // microseconds
|
|
126
|
+
const SORTABLE_BASE32_CHARS = '234567abcdefghijklmnopqrstuvwxyz';
|
|
127
|
+
let lastTidTimestamp = 0;
|
|
128
|
+
let tidClockId: number | undefined;
|
|
133
129
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
let
|
|
137
|
-
let remaining = timestamp;
|
|
130
|
+
function sortableBase32Encode(value: number): string {
|
|
131
|
+
let encoded = '';
|
|
132
|
+
let remaining = value;
|
|
138
133
|
|
|
139
|
-
|
|
140
|
-
|
|
134
|
+
while (remaining > 0) {
|
|
135
|
+
encoded = SORTABLE_BASE32_CHARS[remaining % 32] + encoded;
|
|
141
136
|
remaining = Math.floor(remaining / 32);
|
|
142
137
|
}
|
|
143
138
|
|
|
144
|
-
return
|
|
139
|
+
return encoded;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function getTidClockId(): number {
|
|
143
|
+
tidClockId ??= Math.floor(Math.random() * 1024);
|
|
144
|
+
return tidClockId;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function resetTidStateForTests(): void {
|
|
148
|
+
lastTidTimestamp = 0;
|
|
149
|
+
tidClockId = undefined;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Generate an ATProto Timestamp Identifier for use as a record key or revision.
|
|
154
|
+
*
|
|
155
|
+
* The timestamp portion is microsecond-precision and monotonically increases
|
|
156
|
+
* even when JavaScript only exposes millisecond time or the system clock moves
|
|
157
|
+
* backwards.
|
|
158
|
+
*/
|
|
159
|
+
export function generateTid(): string {
|
|
160
|
+
const nowMicros = Date.now() * 1000;
|
|
161
|
+
const timestamp = Math.max(nowMicros, lastTidTimestamp + 1);
|
|
162
|
+
lastTidTimestamp = timestamp;
|
|
163
|
+
|
|
164
|
+
const timestampPart = sortableBase32Encode(timestamp).padStart(11, '2');
|
|
165
|
+
const clockPart = sortableBase32Encode(getTidClockId()).padStart(2, '2');
|
|
166
|
+
return `${timestampPart}${clockPart}`;
|
|
145
167
|
}
|
|
146
168
|
|
|
147
169
|
/**
|
|
148
170
|
* Validate TID format
|
|
149
171
|
*/
|
|
150
172
|
export function isValidTid(tid: string): boolean {
|
|
151
|
-
return /^[234567abcdefghijklmnopqrstuvwxyz]{
|
|
173
|
+
return /^[234567abcdefghij][234567abcdefghijklmnopqrstuvwxyz]{12}$/.test(tid);
|
|
152
174
|
}
|
package/src/lib/did-document.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Env } from '../env';
|
|
2
|
-
import {
|
|
2
|
+
import { canonicalPdsOrigin, validAtprotoHandle } from './public-host';
|
|
3
3
|
|
|
4
4
|
export interface DidDocument {
|
|
5
5
|
'@context': string[];
|
|
@@ -14,18 +14,17 @@ export interface DidDocument {
|
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
export async function buildDidDocument(env: Env, did: string, handle: string): Promise<DidDocument> {
|
|
17
|
-
const
|
|
18
|
-
|
|
17
|
+
const claimedHandle = validAtprotoHandle(handle);
|
|
19
18
|
return {
|
|
20
19
|
'@context': ['https://www.w3.org/ns/did/v1'],
|
|
21
20
|
id: did,
|
|
22
|
-
alsoKnownAs: [`at://${
|
|
21
|
+
alsoKnownAs: claimedHandle ? [`at://${claimedHandle}`] : [],
|
|
23
22
|
verificationMethod: [],
|
|
24
23
|
service: [
|
|
25
24
|
{
|
|
26
25
|
id: '#atproto_pds',
|
|
27
26
|
type: 'AtprotoPersonalDataServer',
|
|
28
|
-
serviceEndpoint:
|
|
27
|
+
serviceEndpoint: await canonicalPdsOrigin(env),
|
|
29
28
|
},
|
|
30
29
|
],
|
|
31
30
|
};
|
package/src/lib/jwt.ts
CHANGED
|
@@ -30,7 +30,9 @@ export async function signJwt(
|
|
|
30
30
|
throw new Error("Cannot sign JWT without subject");
|
|
31
31
|
}
|
|
32
32
|
const { accessJwt, refreshJwt } = await issueSessionTokens(env, claims.sub, {
|
|
33
|
-
jti: claims.jti,
|
|
33
|
+
jti: kind === "refresh" ? claims.jti : undefined,
|
|
34
|
+
accessJti: kind === "access" ? claims.jti : undefined,
|
|
35
|
+
scope: kind === "access" ? claims.scope : undefined,
|
|
34
36
|
});
|
|
35
37
|
return kind === "access" ? accessJwt : refreshJwt;
|
|
36
38
|
}
|
package/src/lib/mime.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export function mimeMatches(accept: string, mimeType: string): boolean {
|
|
2
|
+
const normalizedAccept = accept.toLowerCase();
|
|
3
|
+
const normalizedMime = mimeType.toLowerCase();
|
|
4
|
+
if (normalizedAccept === '*/*') return true;
|
|
5
|
+
if (normalizedAccept.endsWith('/*')) {
|
|
6
|
+
return normalizedMime.startsWith(normalizedAccept.slice(0, -1));
|
|
7
|
+
}
|
|
8
|
+
return normalizedAccept === normalizedMime;
|
|
9
|
+
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { errorMessage } from '../errors';
|
|
2
2
|
|
|
3
|
+
export type OauthEndpoint = 'par' | 'authorize' | 'consent' | 'token' | 'revoke';
|
|
4
|
+
|
|
3
5
|
export type OauthParStage =
|
|
4
6
|
| 'metadata_fetch'
|
|
5
7
|
| 'metadata_shape'
|
|
@@ -9,6 +11,17 @@ export type OauthParStage =
|
|
|
9
11
|
| 'outer'
|
|
10
12
|
| 'success';
|
|
11
13
|
|
|
14
|
+
export type OauthTokenStage =
|
|
15
|
+
| 'dpop'
|
|
16
|
+
| 'parse'
|
|
17
|
+
| 'unsupported_grant'
|
|
18
|
+
| 'auth_code'
|
|
19
|
+
| 'refresh_token'
|
|
20
|
+
| 'client_auth'
|
|
21
|
+
| 'session_issue'
|
|
22
|
+
| 'outer'
|
|
23
|
+
| 'success';
|
|
24
|
+
|
|
12
25
|
export type OauthParFormSummary = {
|
|
13
26
|
redirectUri: string;
|
|
14
27
|
responseType: string;
|
|
@@ -20,20 +33,30 @@ export type OauthParFormSummary = {
|
|
|
20
33
|
hasClientAssertion: boolean;
|
|
21
34
|
};
|
|
22
35
|
|
|
23
|
-
export type
|
|
36
|
+
export type OauthTokenFormSummary = {
|
|
37
|
+
grantType: string;
|
|
38
|
+
clientId: string;
|
|
39
|
+
redirectUri: string;
|
|
40
|
+
hasCode: boolean;
|
|
41
|
+
hasCodeVerifier: boolean;
|
|
42
|
+
hasRefreshToken: boolean;
|
|
43
|
+
hasClientAssertion: boolean;
|
|
44
|
+
clientAssertionType: string | null;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export type OauthLogDetails = {
|
|
48
|
+
endpoint: OauthEndpoint;
|
|
49
|
+
stage: string;
|
|
24
50
|
outcome: 'ok' | 'error';
|
|
25
51
|
requestId?: string | null;
|
|
26
52
|
error?: unknown;
|
|
27
53
|
clientId?: string | null;
|
|
28
|
-
form?:
|
|
54
|
+
form?: Record<string, unknown> | null;
|
|
29
55
|
metadataStatus?: number | null;
|
|
30
56
|
metadataContentType?: string | null;
|
|
31
57
|
metadataRedirected?: boolean | null;
|
|
32
58
|
};
|
|
33
59
|
|
|
34
|
-
// Read context off an Error attached by safeFetchJson when the metadata HTTP
|
|
35
|
-
// roundtrip itself failed; absent for shape/validation errors that never made
|
|
36
|
-
// a request.
|
|
37
60
|
export type FetchAugmentedError = Error & {
|
|
38
61
|
metadataStatus?: number;
|
|
39
62
|
metadataContentType?: string | null;
|
|
@@ -69,16 +92,25 @@ export function summarizeParForm(form: URLSearchParams): OauthParFormSummary {
|
|
|
69
92
|
};
|
|
70
93
|
}
|
|
71
94
|
|
|
72
|
-
export function
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
95
|
+
export function summarizeTokenForm(form: URLSearchParams): OauthTokenFormSummary {
|
|
96
|
+
return {
|
|
97
|
+
grantType: form.get('grant_type') ?? '',
|
|
98
|
+
clientId: form.get('client_id') ?? '',
|
|
99
|
+
redirectUri: form.get('redirect_uri') ?? '',
|
|
100
|
+
hasCode: !!form.get('code'),
|
|
101
|
+
hasCodeVerifier: !!form.get('code_verifier'),
|
|
102
|
+
hasRefreshToken: !!form.get('refresh_token'),
|
|
103
|
+
hasClientAssertion: !!form.get('client_assertion'),
|
|
104
|
+
clientAssertionType: form.get('client_assertion_type'),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function logOauth(request: Request, details: OauthLogDetails): void {
|
|
77
109
|
const url = new URL(request.url);
|
|
78
110
|
const record = {
|
|
79
111
|
level: details.outcome === 'ok' ? 'info' : 'error',
|
|
80
|
-
type:
|
|
81
|
-
stage,
|
|
112
|
+
type: `oauth_${details.endpoint}`,
|
|
113
|
+
stage: details.stage,
|
|
82
114
|
outcome: details.outcome,
|
|
83
115
|
requestId: details.requestId ?? null,
|
|
84
116
|
method: request.method,
|
|
@@ -97,3 +129,12 @@ export function logOauthPar(
|
|
|
97
129
|
console.error(JSON.stringify(record));
|
|
98
130
|
}
|
|
99
131
|
}
|
|
132
|
+
|
|
133
|
+
// Backward-compatible alias for PAR call sites.
|
|
134
|
+
export function logOauthPar(
|
|
135
|
+
stage: OauthParStage,
|
|
136
|
+
request: Request,
|
|
137
|
+
details: Omit<OauthLogDetails, 'endpoint' | 'stage'>,
|
|
138
|
+
): void {
|
|
139
|
+
logOauth(request, { ...details, endpoint: 'par', stage });
|
|
140
|
+
}
|
|
@@ -3,7 +3,17 @@ 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
5
|
import { cleanupExpiredOAuthReplaySecrets, createSecretOnce, getOAuthSession, getSecret, setSecret } from '../../db/account';
|
|
6
|
+
import { getAccountState } from '../../db/dal';
|
|
6
7
|
import { jwkThumbprint } from './dpop';
|
|
8
|
+
import {
|
|
9
|
+
bearerAccessContext,
|
|
10
|
+
isBearerAccessScope,
|
|
11
|
+
isOAuthPermissionScope,
|
|
12
|
+
oauthAccessContext,
|
|
13
|
+
withAccountStatus,
|
|
14
|
+
type AuthAccessContext,
|
|
15
|
+
type AuthAccountStatus,
|
|
16
|
+
} from '../auth-scope';
|
|
7
17
|
|
|
8
18
|
const NONCE_PDS_KEY = 'oauth_dpop_nonce_pds';
|
|
9
19
|
|
|
@@ -66,6 +76,7 @@ export type ResourceAuthContext = {
|
|
|
66
76
|
token: string;
|
|
67
77
|
scope?: string;
|
|
68
78
|
authType: 'bearer' | 'oauth-dpop';
|
|
79
|
+
access: AuthAccessContext;
|
|
69
80
|
};
|
|
70
81
|
|
|
71
82
|
export async function verifyResourceRequest(env: Env, request: Request): Promise<ResourceAuthContext | null> {
|
|
@@ -86,11 +97,19 @@ export async function verifyResourceRequest(env: Env, request: Request): Promise
|
|
|
86
97
|
|
|
87
98
|
if (scheme === 'bearer') {
|
|
88
99
|
const payload = await verifyAccessTokenOrThrow(env, token, { allowOAuth: false });
|
|
100
|
+
if (!isBearerAccessScope(payload.scope)) {
|
|
101
|
+
throw new ResourceAuthError('invalid_token');
|
|
102
|
+
}
|
|
89
103
|
return {
|
|
90
104
|
did: payload.sub as string,
|
|
91
105
|
token,
|
|
92
106
|
scope: typeof payload.scope === 'string' ? payload.scope : undefined,
|
|
93
107
|
authType: 'bearer',
|
|
108
|
+
access: await withResolvedResourceAccountStatus(
|
|
109
|
+
env,
|
|
110
|
+
payload.sub as string,
|
|
111
|
+
bearerAccessContext(payload.scope),
|
|
112
|
+
),
|
|
94
113
|
};
|
|
95
114
|
}
|
|
96
115
|
|
|
@@ -128,6 +147,9 @@ async function verifyDpopAccess(env: Env, request: Request, accessToken: string)
|
|
|
128
147
|
await importJWK(header.jwk as JoseJWK, 'ES256');
|
|
129
148
|
|
|
130
149
|
const tokenPayload = await verifyAccessTokenOrThrow(env, accessToken, { allowOAuth: true });
|
|
150
|
+
if (!isOAuthPermissionScope(tokenPayload.scope)) {
|
|
151
|
+
throw new ResourceAuthError('invalid_token', { message: 'OAuth token has no PDS resource permissions' });
|
|
152
|
+
}
|
|
131
153
|
const tokenJkt = (tokenPayload.cnf as any)?.jkt;
|
|
132
154
|
if (typeof tokenJkt !== 'string') {
|
|
133
155
|
throw new ResourceAuthError('invalid_token', { message: 'DPoP access token missing cnf.jkt' });
|
|
@@ -142,6 +164,11 @@ async function verifyDpopAccess(env: Env, request: Request, accessToken: string)
|
|
|
142
164
|
token: accessToken,
|
|
143
165
|
scope: typeof tokenPayload.scope === 'string' ? tokenPayload.scope : undefined,
|
|
144
166
|
authType: 'oauth-dpop' as const,
|
|
167
|
+
access: await withResolvedResourceAccountStatus(
|
|
168
|
+
env,
|
|
169
|
+
tokenPayload.sub as string,
|
|
170
|
+
oauthAccessContext(String(tokenPayload.scope)),
|
|
171
|
+
),
|
|
145
172
|
};
|
|
146
173
|
}
|
|
147
174
|
|
|
@@ -238,11 +265,19 @@ export async function verifyResourceRequestHybrid(
|
|
|
238
265
|
|
|
239
266
|
if (scheme === 'bearer') {
|
|
240
267
|
const payloadJwt = await deps.verifyAccessToken(env, token, { allowOAuth: false });
|
|
268
|
+
if (!isBearerAccessScope(payloadJwt.scope)) {
|
|
269
|
+
throw new ResourceAuthError('invalid_token');
|
|
270
|
+
}
|
|
241
271
|
return {
|
|
242
272
|
did: payloadJwt.sub as string,
|
|
243
273
|
token,
|
|
244
274
|
scope: typeof payloadJwt.scope === 'string' ? payloadJwt.scope : undefined,
|
|
245
275
|
authType: 'bearer',
|
|
276
|
+
access: await withResolvedResourceAccountStatus(
|
|
277
|
+
env,
|
|
278
|
+
payloadJwt.sub as string,
|
|
279
|
+
bearerAccessContext(payloadJwt.scope),
|
|
280
|
+
),
|
|
246
281
|
};
|
|
247
282
|
}
|
|
248
283
|
|
|
@@ -256,6 +291,20 @@ function jsonError(error: string, message: string, status: number): Response {
|
|
|
256
291
|
});
|
|
257
292
|
}
|
|
258
293
|
|
|
294
|
+
export function insufficientScopeResponse(): Response {
|
|
295
|
+
return jsonError('InvalidToken', 'token does not grant access to this resource', 401);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async function withResolvedResourceAccountStatus(
|
|
299
|
+
env: Env,
|
|
300
|
+
did: string,
|
|
301
|
+
access: AuthAccessContext,
|
|
302
|
+
): Promise<AuthAccessContext> {
|
|
303
|
+
const state = await getAccountState(env, did);
|
|
304
|
+
const accountStatus: AuthAccountStatus = state?.tag ?? 'active';
|
|
305
|
+
return withAccountStatus(access, accountStatus);
|
|
306
|
+
}
|
|
307
|
+
|
|
259
308
|
export async function handleResourceAuthError(env: Env, error: unknown): Promise<Response | null> {
|
|
260
309
|
if (!(error instanceof ResourceAuthError)) {
|
|
261
310
|
return null;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { AuthAccessContext } from './auth-scope';
|
|
2
|
+
|
|
3
|
+
export const APP_PASSWORD_RESTRICTED_PREFERENCE_TYPES = new Set([
|
|
4
|
+
'app.bsky.actor.defs#personalDetailsPref',
|
|
5
|
+
'app.bsky.actor.defs#bskyAppStatePref',
|
|
6
|
+
]);
|
|
7
|
+
|
|
8
|
+
function preferenceType(value: unknown): string | null {
|
|
9
|
+
if (!value || typeof value !== 'object') return null;
|
|
10
|
+
const type = (value as { $type?: unknown }).$type;
|
|
11
|
+
return typeof type === 'string' ? type : null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function isAppPasswordRestrictedPreference(value: unknown): boolean {
|
|
15
|
+
const type = preferenceType(value);
|
|
16
|
+
return !!type && APP_PASSWORD_RESTRICTED_PREFERENCE_TYPES.has(type);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function preferencesForAccess(
|
|
20
|
+
preferences: unknown[],
|
|
21
|
+
access: AuthAccessContext,
|
|
22
|
+
): unknown[] {
|
|
23
|
+
if (!access.isAppPassword) return preferences;
|
|
24
|
+
return preferences.filter((pref) => !isAppPasswordRestrictedPreference(pref));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function hasAppPasswordRestrictedPreferences(
|
|
28
|
+
preferences: unknown[],
|
|
29
|
+
access: AuthAccessContext,
|
|
30
|
+
): boolean {
|
|
31
|
+
return access.isAppPassword && preferences.some(isAppPasswordRestrictedPreference);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function preferencesForWrite(
|
|
35
|
+
existingPreferences: unknown[],
|
|
36
|
+
nextPreferences: unknown[],
|
|
37
|
+
access: AuthAccessContext,
|
|
38
|
+
): unknown[] {
|
|
39
|
+
if (!access.isAppPassword) return nextPreferences;
|
|
40
|
+
const preserved = existingPreferences.filter(isAppPasswordRestrictedPreference);
|
|
41
|
+
return [
|
|
42
|
+
...nextPreferences.filter((pref) => !isAppPasswordRestrictedPreference(pref)),
|
|
43
|
+
...preserved,
|
|
44
|
+
];
|
|
45
|
+
}
|
package/src/lib/preferences.ts
CHANGED
|
@@ -2,14 +2,10 @@ import type { Env } from '../env';
|
|
|
2
2
|
import { resolveSecret } from './secrets';
|
|
3
3
|
import { ServerMisconfigured } from './errors';
|
|
4
4
|
|
|
5
|
-
let tableEnsured = false;
|
|
6
|
-
|
|
7
5
|
async function ensureTable(env: Env) {
|
|
8
|
-
if (tableEnsured) return;
|
|
9
6
|
await env.ALTERAN_DB.exec(
|
|
10
7
|
'CREATE TABLE IF NOT EXISTS actor_preferences (did TEXT PRIMARY KEY, json TEXT NOT NULL, updated_at INTEGER NOT NULL)'
|
|
11
8
|
);
|
|
12
|
-
tableEnsured = true;
|
|
13
9
|
}
|
|
14
10
|
|
|
15
11
|
// No defaults — return empty when nothing stored to avoid local fallbacks
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import type { Env } from '../env';
|
|
2
|
+
import { getRuntimeString } from './secrets';
|
|
3
|
+
|
|
4
|
+
export type PublicOrigin = {
|
|
5
|
+
origin: string;
|
|
6
|
+
hostname: string;
|
|
7
|
+
host: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const HANDLE_SYNTAX = /^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]([a-z0-9-]{0,61}[a-z0-9])?$/i;
|
|
11
|
+
const DISALLOWED_RESOLUTION_TLDS = new Set([
|
|
12
|
+
'alt',
|
|
13
|
+
'arpa',
|
|
14
|
+
'example',
|
|
15
|
+
'internal',
|
|
16
|
+
'invalid',
|
|
17
|
+
'local',
|
|
18
|
+
'localhost',
|
|
19
|
+
'onion',
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
export async function configuredDid(env: Env): Promise<string> {
|
|
23
|
+
return (await getRuntimeString(env, 'PDS_DID', 'did:example:single-user')) ?? 'did:example:single-user';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function configuredHandleValue(env: Env): Promise<string> {
|
|
27
|
+
return (await getRuntimeString(env, 'PDS_HANDLE', 'user.example.com')) ?? 'user.example.com';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function configuredHandle(env: Env): Promise<string> {
|
|
31
|
+
const value = await configuredHandleValue(env);
|
|
32
|
+
return validAtprotoHandle(value) ?? value.trim().toLowerCase();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function validAtprotoHandle(handle: string | undefined | null): string | null {
|
|
36
|
+
if (typeof handle !== 'string') return null;
|
|
37
|
+
if (handle === '' || handle.length > 253) return null;
|
|
38
|
+
const normalized = handle.toLowerCase();
|
|
39
|
+
if (!HANDLE_SYNTAX.test(normalized)) return null;
|
|
40
|
+
|
|
41
|
+
const tld = normalized.slice(normalized.lastIndexOf('.') + 1);
|
|
42
|
+
if (DISALLOWED_RESOLUTION_TLDS.has(tld)) return null;
|
|
43
|
+
|
|
44
|
+
return normalized;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function configuredAtprotoHandle(env: Env): Promise<string | null> {
|
|
48
|
+
return validAtprotoHandle(await configuredHandleValue(env));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function canonicalPdsOrigin(env: Env): Promise<string> {
|
|
52
|
+
const configuredHost = await getRuntimeString(env, 'PDS_HOSTNAME', '');
|
|
53
|
+
const handle = await configuredHandle(env);
|
|
54
|
+
const parsed = parsePublicOrigin(configuredHost || handle);
|
|
55
|
+
return parsed?.origin ?? `https://${handle}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function canonicalPdsHost(env: Env): Promise<string | null> {
|
|
59
|
+
const configuredHost = await getRuntimeString(env, 'PDS_HOSTNAME', '');
|
|
60
|
+
const handle = await configuredHandle(env);
|
|
61
|
+
return parsePublicOrigin(configuredHost || handle)?.hostname ?? null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function configuredHandleHost(env: Env): Promise<string | null> {
|
|
65
|
+
return configuredAtprotoHandle(env);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function requestHostname(request: Request): string | null {
|
|
69
|
+
try {
|
|
70
|
+
return normalizeHostname(new URL(request.url).hostname);
|
|
71
|
+
} catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function requestMatchesConfiguredHandle(request: Request, env: Env): Promise<boolean> {
|
|
77
|
+
const actual = requestHostname(request);
|
|
78
|
+
const expected = await configuredHandleHost(env);
|
|
79
|
+
return !!actual && !!expected && actual === expected;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function handleResolvesToDid(env: Env, handle: string, did: string): Promise<boolean> {
|
|
83
|
+
const normalizedHandle = validAtprotoHandle(handle);
|
|
84
|
+
const expectedHandle = await configuredHandle(env);
|
|
85
|
+
const expectedDid = await configuredDid(env);
|
|
86
|
+
return !!normalizedHandle &&
|
|
87
|
+
normalizedHandle === expectedHandle &&
|
|
88
|
+
did === expectedDid &&
|
|
89
|
+
!!(await configuredAtprotoHandle(env));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function didDocClaimsHandle(didDoc: { alsoKnownAs?: unknown }, handle: string): boolean {
|
|
93
|
+
const normalizedHandle = validAtprotoHandle(handle);
|
|
94
|
+
if (!normalizedHandle) return false;
|
|
95
|
+
return Array.isArray(didDoc.alsoKnownAs) &&
|
|
96
|
+
didDoc.alsoKnownAs.includes(`at://${normalizedHandle}`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function isLocalHostname(hostname: string): boolean {
|
|
100
|
+
const lower = normalizeHostname(hostname);
|
|
101
|
+
return lower === 'localhost' ||
|
|
102
|
+
lower.endsWith('.localhost') ||
|
|
103
|
+
lower === '127.0.0.1' ||
|
|
104
|
+
lower === '0.0.0.0' ||
|
|
105
|
+
lower === '::1';
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function normalizeHostname(hostname: string): string {
|
|
109
|
+
return hostname.trim().toLowerCase().replace(/^\[(.*)\]$/, '$1');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function parsePublicOrigin(raw: string | undefined | null): PublicOrigin | null {
|
|
113
|
+
const value = raw?.trim();
|
|
114
|
+
if (!value) return null;
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
const url = new URL(/^https?:\/\//i.test(value) ? value : `https://${value}`);
|
|
118
|
+
if (!url.hostname) return null;
|
|
119
|
+
return {
|
|
120
|
+
origin: `https://${url.host}`,
|
|
121
|
+
hostname: normalizeHostname(url.hostname),
|
|
122
|
+
host: url.host.toLowerCase(),
|
|
123
|
+
};
|
|
124
|
+
} catch {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
}
|