@alteran/astro 0.3.9 → 0.6.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/LICENSE +21 -0
- package/README.md +19 -30
- package/index.js +34 -28
- package/migrations/0007_bored_spitfire.sql +26 -0
- package/migrations/0008_furry_ozymandias.sql +2 -0
- package/migrations/meta/0007_snapshot.json +534 -0
- package/migrations/meta/0008_snapshot.json +548 -0
- package/migrations/meta/_journal.json +14 -0
- package/package.json +10 -9
- package/src/app.ts +8 -4
- package/src/db/account.ts +25 -6
- package/src/db/client.ts +1 -1
- package/src/db/dal.ts +34 -23
- package/src/db/repo.ts +38 -38
- package/src/db/schema.ts +5 -1
- package/src/db/seed.ts +5 -13
- package/src/entrypoints/server.ts +2 -22
- package/src/handlers/debug.ts +1 -1
- package/src/handlers/ready.ts +1 -1
- package/src/handlers/root.ts +4 -4
- package/src/handlers/xrpc.server.refreshSession.ts +6 -6
- package/src/lib/account-state.ts +156 -0
- package/src/lib/actor.ts +29 -13
- package/src/lib/appview/auth-policy.ts +66 -0
- package/src/lib/appview/did-resolver.ts +233 -0
- package/src/lib/appview/proxy.ts +221 -0
- package/src/lib/appview/service-config.ts +61 -0
- package/src/lib/appview/service-jwt.ts +93 -0
- package/src/lib/appview/types.ts +25 -0
- package/src/lib/appview.ts +5 -532
- package/src/lib/auth-errors.ts +24 -0
- package/src/lib/auth.ts +63 -15
- package/src/lib/blockstore-gc.ts +6 -5
- package/src/lib/cache.ts +30 -4
- package/src/lib/chat.ts +20 -14
- package/src/lib/commit-log-pruning.ts +2 -2
- package/src/lib/commit.ts +26 -36
- package/src/lib/config.ts +26 -15
- package/src/lib/did-document.ts +32 -0
- package/src/lib/errors.ts +54 -0
- package/src/lib/feed.ts +18 -19
- package/src/lib/firehose/frames.ts +87 -47
- package/src/lib/firehose/validation.ts +3 -3
- package/src/lib/jwt.ts +85 -177
- package/src/lib/labeler.ts +43 -30
- package/src/lib/logger.ts +4 -0
- package/src/lib/mst/block-map.ts +172 -0
- package/src/lib/mst/blockstore.ts +56 -93
- package/src/lib/mst/index.ts +1 -0
- package/src/lib/mst/leaf.ts +25 -0
- package/src/lib/mst/mst.ts +81 -237
- package/src/lib/mst/serialize.ts +97 -0
- package/src/lib/mst/types.ts +21 -0
- package/src/lib/oauth/clients.ts +67 -0
- package/src/lib/oauth/dpop-errors.ts +15 -0
- package/src/lib/oauth/dpop.ts +150 -0
- package/src/lib/oauth/resource.ts +199 -0
- package/src/lib/oauth/store.ts +77 -0
- package/src/lib/preferences.ts +12 -37
- package/src/lib/ratelimit.ts +4 -4
- package/src/lib/refresh-session.ts +161 -0
- package/src/lib/relay.ts +10 -8
- package/src/lib/secrets.ts +6 -7
- package/src/lib/sequencer.ts +14 -5
- package/src/lib/service-auth.ts +184 -0
- package/src/lib/session-tokens.ts +28 -76
- package/src/lib/streaming-car.ts +3 -0
- package/src/lib/tracing.ts +4 -3
- package/src/lib/util.ts +65 -15
- package/src/middleware.ts +1 -1
- package/src/pages/.well-known/did.json.ts +27 -30
- package/src/pages/.well-known/oauth-authorization-server.ts +31 -0
- package/src/pages/.well-known/oauth-protected-resource.ts +22 -0
- package/src/pages/debug/blob/[...key].ts +2 -2
- package/src/pages/debug/db/bootstrap.ts +1 -1
- package/src/pages/debug/db/commits.ts +1 -1
- package/src/pages/debug/gc/blobs.ts +1 -1
- package/src/pages/debug/record.ts +1 -1
- package/src/pages/debug/sequencer.ts +28 -0
- package/src/pages/health.ts +4 -4
- package/src/pages/oauth/authorize.ts +78 -0
- package/src/pages/oauth/consent.ts +80 -0
- package/src/pages/oauth/par.ts +121 -0
- package/src/pages/oauth/token.ts +158 -0
- package/src/pages/ready.ts +2 -2
- package/src/pages/xrpc/[...nsid].ts +61 -0
- package/src/pages/xrpc/app.bsky.actor.getPreferences.ts +12 -13
- package/src/pages/xrpc/app.bsky.actor.putPreferences.ts +23 -23
- package/src/pages/xrpc/app.bsky.unspecced.getAgeAssuranceState.ts +9 -2
- package/src/pages/xrpc/chat.bsky.convo.getLog.ts +9 -2
- package/src/pages/xrpc/chat.bsky.convo.listConvos.ts +9 -2
- package/src/pages/xrpc/com.atproto.identity.getRecommendedDidCredentials.ts +43 -41
- package/src/pages/xrpc/com.atproto.identity.requestPlcOperationSignature.ts +10 -3
- package/src/pages/xrpc/com.atproto.identity.resolveHandle.ts +40 -9
- package/src/pages/xrpc/com.atproto.identity.signPlcOperation.ts +41 -29
- package/src/pages/xrpc/com.atproto.identity.submitPlcOperation.ts +20 -6
- package/src/pages/xrpc/com.atproto.identity.updateHandle.ts +1 -1
- package/src/pages/xrpc/com.atproto.repo.applyWrites.ts +101 -11
- package/src/pages/xrpc/com.atproto.repo.createRecord.ts +44 -14
- package/src/pages/xrpc/com.atproto.repo.deleteRecord.ts +41 -13
- package/src/pages/xrpc/com.atproto.repo.describeRepo.ts +2 -2
- package/src/pages/xrpc/com.atproto.repo.getRecord.ts +14 -1
- package/src/pages/xrpc/com.atproto.repo.listMissingBlobs.ts +14 -6
- package/src/pages/xrpc/com.atproto.repo.listRecords.ts +1 -1
- package/src/pages/xrpc/com.atproto.repo.putRecord.ts +42 -14
- package/src/pages/xrpc/com.atproto.repo.uploadBlob.ts +76 -15
- package/src/pages/xrpc/com.atproto.server.checkAccountStatus.ts +20 -8
- package/src/pages/xrpc/com.atproto.server.createSession.ts +32 -12
- package/src/pages/xrpc/com.atproto.server.describeServer.ts +1 -1
- package/src/pages/xrpc/com.atproto.server.getServiceAuth.ts +12 -5
- package/src/pages/xrpc/com.atproto.server.getSession.ts +22 -8
- package/src/pages/xrpc/com.atproto.server.refreshSession.ts +30 -72
- package/src/pages/xrpc/com.atproto.sync.getBlob.ts +72 -23
- package/src/pages/xrpc/com.atproto.sync.getCheckout.json.ts +1 -1
- package/src/pages/xrpc/com.atproto.sync.getCheckout.ts +1 -1
- package/src/pages/xrpc/com.atproto.sync.getHead.ts +7 -2
- package/src/pages/xrpc/com.atproto.sync.getLatestCommit.ts +1 -1
- package/src/pages/xrpc/com.atproto.sync.getRecord.ts +5 -27
- package/src/pages/xrpc/com.atproto.sync.getRepo.json.ts +1 -1
- package/src/pages/xrpc/com.atproto.sync.getRepo.ts +50 -5
- package/src/pages/xrpc/com.atproto.sync.getRepoStatus.ts +58 -0
- package/src/pages/xrpc/com.atproto.sync.listBlobs.ts +2 -2
- package/src/pages/xrpc/com.atproto.sync.listRepos.ts +5 -3
- package/src/services/car.ts +209 -57
- package/src/services/r2-blob-store.ts +4 -4
- package/src/services/repo/blockstore-ops.ts +29 -0
- package/src/services/repo/operations.ts +133 -0
- package/src/services/repo-manager.ts +203 -254
- package/src/worker/runtime.ts +56 -11
- package/src/worker/sequencer/broadcast.ts +91 -0
- package/src/worker/sequencer/cid-helpers.ts +39 -0
- package/src/worker/sequencer/payload.ts +84 -0
- package/src/worker/sequencer/types.ts +36 -0
- package/src/worker/sequencer/upgrade.ts +141 -0
- package/src/worker/sequencer.ts +264 -406
- package/types/env.d.ts +18 -6
- package/src/pages/xrpc/app.bsky.actor.getProfile.ts +0 -49
- package/src/pages/xrpc/app.bsky.actor.getProfiles.ts +0 -51
- package/src/pages/xrpc/app.bsky.feed.getActorFeeds.ts +0 -25
- package/src/pages/xrpc/app.bsky.feed.getAuthorFeed.ts +0 -42
- package/src/pages/xrpc/app.bsky.feed.getFeedGenerators.ts +0 -25
- package/src/pages/xrpc/app.bsky.feed.getPostThread.ts +0 -37
- package/src/pages/xrpc/app.bsky.feed.getPosts.ts +0 -26
- package/src/pages/xrpc/app.bsky.feed.getSuggestedFeeds.ts +0 -23
- package/src/pages/xrpc/app.bsky.feed.getTimeline.ts +0 -47
- package/src/pages/xrpc/app.bsky.graph.getFollowers.ts +0 -29
- package/src/pages/xrpc/app.bsky.graph.getFollows.ts +0 -29
- package/src/pages/xrpc/app.bsky.notification.getUnreadCount.ts +0 -20
- package/src/pages/xrpc/app.bsky.notification.listNotifications.ts +0 -27
- package/src/pages/xrpc/app.bsky.unspecced.getSuggestedFeeds.ts +0 -23
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Refresh-session validation as a small, total state machine.
|
|
3
|
+
*
|
|
4
|
+
* The XRPC handler used to be ~100 lines of linear guards that each
|
|
5
|
+
* returned a different Response shape. Lifting the checks into one
|
|
6
|
+
* function that returns a discriminated `RefreshOutcome` lets the
|
|
7
|
+
* handler reduce to a single switch, and lets us unit-test every
|
|
8
|
+
* decision branch without an HTTP layer.
|
|
9
|
+
*/
|
|
10
|
+
import type { Env } from '../env';
|
|
11
|
+
import { InvalidToken } from './errors';
|
|
12
|
+
import { getRuntimeString } from './secrets';
|
|
13
|
+
import {
|
|
14
|
+
getAccountByIdentifier,
|
|
15
|
+
getRefreshToken,
|
|
16
|
+
markRefreshTokenRotated,
|
|
17
|
+
storeRefreshToken,
|
|
18
|
+
type RefreshTokenRow,
|
|
19
|
+
} from '../db/account';
|
|
20
|
+
import {
|
|
21
|
+
verifyRefreshToken,
|
|
22
|
+
issueSessionTokens,
|
|
23
|
+
computeGraceExpiry,
|
|
24
|
+
} from './session-tokens';
|
|
25
|
+
|
|
26
|
+
export type RefreshFailureCode =
|
|
27
|
+
| 'AuthRequired'
|
|
28
|
+
| 'InvalidToken'
|
|
29
|
+
| 'ExpiredToken';
|
|
30
|
+
|
|
31
|
+
export type RefreshFailure = {
|
|
32
|
+
readonly tag: 'failure';
|
|
33
|
+
readonly code: RefreshFailureCode;
|
|
34
|
+
readonly message: string;
|
|
35
|
+
readonly status: number;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type RefreshSuccess = {
|
|
39
|
+
readonly tag: 'success';
|
|
40
|
+
readonly did: string;
|
|
41
|
+
readonly handle: string;
|
|
42
|
+
readonly accessJwt: string;
|
|
43
|
+
readonly refreshJwt: string;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export type RefreshOutcome = RefreshFailure | RefreshSuccess;
|
|
47
|
+
|
|
48
|
+
function failure(code: RefreshFailureCode, message: string, status = 401): RefreshFailure {
|
|
49
|
+
return { tag: 'failure', code, message, status };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
type AttemptInput = {
|
|
53
|
+
readonly env: Env;
|
|
54
|
+
readonly token: string | null;
|
|
55
|
+
readonly nowSec: number;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Pure-ish coordinator: every branch either fails fast or yields a
|
|
60
|
+
* concrete RefreshSuccess. The handler stays thin and the test surface
|
|
61
|
+
* is the return value rather than HTTP response shape.
|
|
62
|
+
*/
|
|
63
|
+
export async function attemptRefresh({ env, token, nowSec }: AttemptInput): Promise<RefreshOutcome> {
|
|
64
|
+
if (!token) {
|
|
65
|
+
return failure('AuthRequired', 'No authorization token provided');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Catch only token-shape failures here; configuration errors must propagate
|
|
69
|
+
// so they surface as 5xx instead of being masked as a 401.
|
|
70
|
+
let verification: Awaited<ReturnType<typeof verifyRefreshToken>> | null;
|
|
71
|
+
try {
|
|
72
|
+
verification = await verifyRefreshToken(env, token);
|
|
73
|
+
} catch (error) {
|
|
74
|
+
if (error instanceof InvalidToken) {
|
|
75
|
+
verification = null;
|
|
76
|
+
} else {
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (!verification) {
|
|
81
|
+
return failure('InvalidToken', 'Invalid or expired refresh token');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const { decoded } = verification;
|
|
85
|
+
if (
|
|
86
|
+
!decoded ||
|
|
87
|
+
typeof decoded.jti !== 'string' ||
|
|
88
|
+
typeof decoded.sub !== 'string'
|
|
89
|
+
) {
|
|
90
|
+
return failure('InvalidToken', 'Malformed refresh token');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (typeof decoded.exp !== 'number' || decoded.exp <= nowSec) {
|
|
94
|
+
return failure('ExpiredToken', 'Refresh token expired');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const stored = await getRefreshToken(env, decoded.jti);
|
|
98
|
+
if (!stored) {
|
|
99
|
+
return failure('InvalidToken', 'Refresh token has been revoked');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (stored.expiresAt <= nowSec) {
|
|
103
|
+
return failure('ExpiredToken', 'Refresh token expired');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (stored.did !== decoded.sub) {
|
|
107
|
+
return failure('InvalidToken', 'Token subject mismatch');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return finalizeRotation({ env, stored, expiredJti: decoded.jti, nowSec });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
type FinalizeInput = {
|
|
114
|
+
readonly env: Env;
|
|
115
|
+
readonly stored: RefreshTokenRow;
|
|
116
|
+
readonly expiredJti: string;
|
|
117
|
+
readonly nowSec: number;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
async function finalizeRotation({
|
|
121
|
+
env,
|
|
122
|
+
stored,
|
|
123
|
+
expiredJti,
|
|
124
|
+
nowSec,
|
|
125
|
+
}: FinalizeInput): Promise<RefreshOutcome> {
|
|
126
|
+
const account = await getAccountByIdentifier(env, stored.did);
|
|
127
|
+
const did = stored.did;
|
|
128
|
+
const handle =
|
|
129
|
+
account?.handle ?? (await getRuntimeString(env, 'PDS_HANDLE', 'user.example')) ?? 'user.example';
|
|
130
|
+
|
|
131
|
+
// If a previous rotation already chose the next JTI we MUST reuse it so
|
|
132
|
+
// a client retrying inside the grace window receives the same pair.
|
|
133
|
+
const desiredJti = stored.nextId ?? undefined;
|
|
134
|
+
|
|
135
|
+
const { accessJwt, refreshJwt, refreshPayload, refreshExpiry } = await issueSessionTokens(
|
|
136
|
+
env,
|
|
137
|
+
did,
|
|
138
|
+
{ jti: desiredJti },
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
// Reuse attack detection: the stored nextId fixes what the client is
|
|
142
|
+
// allowed to see; any divergence means the same refresh was already used
|
|
143
|
+
// to mint a *different* successor and this attempt is poisoned.
|
|
144
|
+
if (stored.nextId && stored.nextId !== refreshPayload.jti) {
|
|
145
|
+
return failure('InvalidToken', 'Refresh token has been revoked');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (!stored.nextId) {
|
|
149
|
+
await storeRefreshToken(env, {
|
|
150
|
+
id: refreshPayload.jti,
|
|
151
|
+
did,
|
|
152
|
+
expiresAt: refreshExpiry,
|
|
153
|
+
appPasswordName: stored.appPasswordName ?? null,
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
const graceExpiry = computeGraceExpiry(stored.expiresAt, nowSec);
|
|
157
|
+
await markRefreshTokenRotated(env, expiredJti, refreshPayload.jti, graceExpiry);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return { tag: 'success', did, handle, accessJwt, refreshJwt };
|
|
161
|
+
}
|
package/src/lib/relay.ts
CHANGED
|
@@ -12,7 +12,9 @@ export function resolvePdsHostname(env: Env, requestUrl?: string): string | null
|
|
|
12
12
|
try {
|
|
13
13
|
const url = new URL(requestUrl);
|
|
14
14
|
host = url.hostname;
|
|
15
|
-
} catch {
|
|
15
|
+
} catch {
|
|
16
|
+
// Malformed request URL: leave host unset so the caller can choose a fallback.
|
|
17
|
+
}
|
|
16
18
|
}
|
|
17
19
|
|
|
18
20
|
if (!host) return null;
|
|
@@ -53,12 +55,12 @@ export function getRelayHosts(env: Env): string[] {
|
|
|
53
55
|
*/
|
|
54
56
|
export async function requestCrawl(relayHost: string, pdsHostname: string): Promise<Response> {
|
|
55
57
|
const url = `https://${relayHost}/xrpc/com.atproto.sync.requestCrawl`;
|
|
56
|
-
const
|
|
58
|
+
const response = await fetch(url, {
|
|
57
59
|
method: 'POST',
|
|
58
60
|
headers: { 'content-type': 'application/json' },
|
|
59
61
|
body: JSON.stringify({ hostname: pdsHostname }),
|
|
60
62
|
});
|
|
61
|
-
return
|
|
63
|
+
return response;
|
|
62
64
|
}
|
|
63
65
|
|
|
64
66
|
// In-memory isolation-scoped throttle to avoid spamming relays on every request.
|
|
@@ -88,12 +90,12 @@ export async function notifyRelaysIfNeeded(env: Env, requestUrl?: string): Promi
|
|
|
88
90
|
await Promise.allSettled(
|
|
89
91
|
relays.map(async (relay) => {
|
|
90
92
|
try {
|
|
91
|
-
const
|
|
92
|
-
if (!
|
|
93
|
-
console.warn('requestCrawl failed', { relay, status:
|
|
93
|
+
const response = await requestCrawl(relay, hostname);
|
|
94
|
+
if (!response.ok) {
|
|
95
|
+
console.warn('requestCrawl failed', { relay, status: response.status });
|
|
94
96
|
}
|
|
95
|
-
} catch (
|
|
96
|
-
console.warn('requestCrawl error', { relay, error: String(
|
|
97
|
+
} catch (error) {
|
|
98
|
+
console.warn('requestCrawl error', { relay, error: String(error) });
|
|
97
99
|
}
|
|
98
100
|
}),
|
|
99
101
|
);
|
package/src/lib/secrets.ts
CHANGED
|
@@ -10,16 +10,15 @@ const SECRET_KEYS = [
|
|
|
10
10
|
"REFRESH_TOKEN_SECRET",
|
|
11
11
|
"SESSION_JWT_SECRET",
|
|
12
12
|
"REPO_SIGNING_KEY",
|
|
13
|
-
"REPO_SIGNING_KEY_PUBLIC",
|
|
14
13
|
"PDS_PLC_ROTATION_KEY",
|
|
15
|
-
"PDS_SERVICE_SIGNING_KEY_HEX",
|
|
16
14
|
] as const satisfies readonly (keyof Env)[];
|
|
17
15
|
|
|
18
16
|
function isSecretStoreBinding(value: unknown): value is SecretsStoreSecret {
|
|
19
17
|
return (
|
|
20
18
|
!!value &&
|
|
21
19
|
typeof value === "object" &&
|
|
22
|
-
|
|
20
|
+
"get" in value &&
|
|
21
|
+
typeof (value as { get: unknown }).get === "function"
|
|
23
22
|
);
|
|
24
23
|
}
|
|
25
24
|
|
|
@@ -37,13 +36,13 @@ export async function resolveSecret(
|
|
|
37
36
|
* Non-secret bindings (DB, BLOBS, SEQUENCER, vars) are preserved as-is.
|
|
38
37
|
*/
|
|
39
38
|
export async function resolveEnvSecrets<E extends Env>(env: E): Promise<E> {
|
|
40
|
-
const resolved
|
|
39
|
+
const resolved = { ...env } as Record<string, unknown>;
|
|
41
40
|
|
|
42
41
|
await Promise.all(
|
|
43
42
|
SECRET_KEYS.map(async (key) => {
|
|
44
|
-
const
|
|
45
|
-
if (
|
|
46
|
-
resolved[key as string] =
|
|
43
|
+
const value = await resolveSecret(env[key]);
|
|
44
|
+
if (value !== undefined) {
|
|
45
|
+
resolved[key as string] = value;
|
|
47
46
|
}
|
|
48
47
|
}),
|
|
49
48
|
);
|
package/src/lib/sequencer.ts
CHANGED
|
@@ -1,10 +1,19 @@
|
|
|
1
1
|
import type { Env } from '../env';
|
|
2
2
|
|
|
3
3
|
export async function notifySequencer(env: Env, obj: unknown) {
|
|
4
|
-
if (!env.
|
|
4
|
+
if (!env.ALTERAN_SEQUENCER) {
|
|
5
|
+
console.warn('notifySequencer: SEQUENCER binding missing');
|
|
6
|
+
return;
|
|
7
|
+
}
|
|
5
8
|
try {
|
|
6
|
-
const id = env.
|
|
7
|
-
const stub = env.
|
|
8
|
-
await stub.fetch('https://sequencer/commit', {
|
|
9
|
-
|
|
9
|
+
const id = env.ALTERAN_SEQUENCER.idFromName('default');
|
|
10
|
+
const stub = env.ALTERAN_SEQUENCER.get(id);
|
|
11
|
+
await stub.fetch('https://sequencer/commit', {
|
|
12
|
+
method: 'POST',
|
|
13
|
+
headers: { 'content-type': 'application/json' },
|
|
14
|
+
body: JSON.stringify(obj),
|
|
15
|
+
});
|
|
16
|
+
} catch (e) {
|
|
17
|
+
console.warn('notifySequencer: failed to POST /commit to sequencer', e);
|
|
18
|
+
}
|
|
10
19
|
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import type { Env } from '../env';
|
|
2
|
+
import { resolveSecret } from './secrets';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Service auth verification for external services (like video.bsky.app)
|
|
6
|
+
*
|
|
7
|
+
* Service auth JWTs are signed by the service's key and contain:
|
|
8
|
+
* - iss: The service's DID (e.g., did:web:video.bsky.app)
|
|
9
|
+
* - aud: The PDS's DID (must match our PDS_DID)
|
|
10
|
+
* - lxm: The lexicon method being authorized
|
|
11
|
+
* - exp/iat: Standard JWT timing claims
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
interface ServiceAuthPayload {
|
|
15
|
+
iss: string;
|
|
16
|
+
aud: string;
|
|
17
|
+
lxm?: string;
|
|
18
|
+
exp?: number;
|
|
19
|
+
iat?: number;
|
|
20
|
+
jti?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface ServiceAuthResult {
|
|
24
|
+
iss: string;
|
|
25
|
+
aud: string;
|
|
26
|
+
lxm?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Check if a Bearer token is a service auth token (has lxm claim)
|
|
31
|
+
*/
|
|
32
|
+
export function isServiceAuthToken(token: string): boolean {
|
|
33
|
+
try {
|
|
34
|
+
const parts = token.split('.');
|
|
35
|
+
if (parts.length !== 3) return false;
|
|
36
|
+
const payload = JSON.parse(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/')));
|
|
37
|
+
return payload.lxm != null;
|
|
38
|
+
} catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Decode JWT payload without verification (for initial inspection)
|
|
45
|
+
*/
|
|
46
|
+
function decodeJwtPayload(token: string): ServiceAuthPayload | null {
|
|
47
|
+
try {
|
|
48
|
+
const parts = token.split('.');
|
|
49
|
+
if (parts.length !== 3) return null;
|
|
50
|
+
const payload = JSON.parse(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/')));
|
|
51
|
+
return payload;
|
|
52
|
+
} catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Resolve a DID document and extract the atproto verification key
|
|
59
|
+
*/
|
|
60
|
+
async function getVerificationKey(did: string): Promise<string | null> {
|
|
61
|
+
try {
|
|
62
|
+
let url: string;
|
|
63
|
+
if (did.startsWith('did:web:')) {
|
|
64
|
+
const host = did.slice('did:web:'.length).replace(/:/g, '/');
|
|
65
|
+
url = `https://${host}/.well-known/did.json`;
|
|
66
|
+
} else if (did.startsWith('did:plc:')) {
|
|
67
|
+
url = `https://plc.directory/${did}`;
|
|
68
|
+
} else {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const response = await fetch(url, {
|
|
73
|
+
headers: { 'Accept': 'application/json' },
|
|
74
|
+
});
|
|
75
|
+
if (!response.ok) return null;
|
|
76
|
+
|
|
77
|
+
const doc = await response.json() as any;
|
|
78
|
+
|
|
79
|
+
// Find atproto verification method
|
|
80
|
+
const methods = doc.verificationMethod || [];
|
|
81
|
+
for (const method of methods) {
|
|
82
|
+
if (method.id?.endsWith('#atproto') && method.publicKeyMultibase) {
|
|
83
|
+
return method.publicKeyMultibase;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
} catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Verify ES256K signature using the public key from DID document
|
|
94
|
+
*/
|
|
95
|
+
async function verifyES256KSignature(
|
|
96
|
+
token: string,
|
|
97
|
+
publicKeyMultibase: string
|
|
98
|
+
): Promise<boolean> {
|
|
99
|
+
try {
|
|
100
|
+
const { Secp256k1Keypair } = await import('@atproto/crypto');
|
|
101
|
+
|
|
102
|
+
const parts = token.split('.');
|
|
103
|
+
if (parts.length !== 3) return false;
|
|
104
|
+
|
|
105
|
+
const [headerB64, payloadB64, signatureB64] = parts;
|
|
106
|
+
const signingInput = `${headerB64}.${payloadB64}`;
|
|
107
|
+
|
|
108
|
+
// Decode signature from base64url
|
|
109
|
+
const sigB64 = signatureB64.replace(/-/g, '+').replace(/_/g, '/');
|
|
110
|
+
const sigBin = atob(sigB64);
|
|
111
|
+
const signature = new Uint8Array(sigBin.length);
|
|
112
|
+
for (let i = 0; i < sigBin.length; i++) {
|
|
113
|
+
signature[i] = sigBin.charCodeAt(i);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// The multibase key starts with 'z' for base58btc encoding
|
|
117
|
+
// Format: z + multicodec prefix (0xe7 0x01 for secp256k1) + compressed public key
|
|
118
|
+
if (!publicKeyMultibase.startsWith('z')) {
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Use @atproto/crypto to verify
|
|
123
|
+
const { verifySignature } = await import('@atproto/crypto');
|
|
124
|
+
const data = new TextEncoder().encode(signingInput);
|
|
125
|
+
|
|
126
|
+
// Convert multibase to did:key format for verification
|
|
127
|
+
const didKey = `did:key:${publicKeyMultibase}`;
|
|
128
|
+
const isValid = await verifySignature(didKey, data, signature);
|
|
129
|
+
|
|
130
|
+
return isValid;
|
|
131
|
+
} catch (e) {
|
|
132
|
+
console.error('Service auth signature verification failed:', e);
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Verify a service auth request
|
|
139
|
+
*
|
|
140
|
+
* @param env - Environment with PDS_DID
|
|
141
|
+
* @param request - The incoming request
|
|
142
|
+
* @returns The verified service auth payload, or null if not service auth / invalid
|
|
143
|
+
*/
|
|
144
|
+
export async function verifyServiceAuth(
|
|
145
|
+
env: Env,
|
|
146
|
+
request: Request
|
|
147
|
+
): Promise<ServiceAuthResult | null> {
|
|
148
|
+
const auth = request.headers.get('authorization');
|
|
149
|
+
if (!auth?.startsWith('Bearer ')) return null;
|
|
150
|
+
|
|
151
|
+
const token = auth.slice(7).trim();
|
|
152
|
+
if (!isServiceAuthToken(token)) return null;
|
|
153
|
+
|
|
154
|
+
const payload = decodeJwtPayload(token);
|
|
155
|
+
if (!payload || !payload.iss || !payload.aud) return null;
|
|
156
|
+
|
|
157
|
+
// Check audience matches our PDS DID (accept both did:plc and did:web forms)
|
|
158
|
+
const pdsDid = await resolveSecret(env.PDS_DID);
|
|
159
|
+
const hostname = env.PDS_HOSTNAME || env.PDS_HANDLE;
|
|
160
|
+
const didWebAlt = hostname ? `did:web:${hostname}` : null;
|
|
161
|
+
const validAudiences = [pdsDid, didWebAlt].filter(Boolean) as string[];
|
|
162
|
+
|
|
163
|
+
if (!validAudiences.includes(payload.aud)) return null;
|
|
164
|
+
|
|
165
|
+
// Check expiration
|
|
166
|
+
if (payload.exp) {
|
|
167
|
+
const now = Math.floor(Date.now() / 1000);
|
|
168
|
+
if (payload.exp < now) return null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Get the issuer's verification key from their DID document
|
|
172
|
+
const publicKey = await getVerificationKey(payload.iss);
|
|
173
|
+
if (!publicKey) return null;
|
|
174
|
+
|
|
175
|
+
// Verify the signature
|
|
176
|
+
const isValid = await verifyES256KSignature(token, publicKey);
|
|
177
|
+
if (!isValid) return null;
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
iss: payload.iss,
|
|
181
|
+
aud: payload.aud,
|
|
182
|
+
lxm: payload.lxm,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
@@ -2,6 +2,8 @@ import { bytesToHex, randomBytes } from '@noble/hashes/utils.js';
|
|
|
2
2
|
import type { Env } from '../env';
|
|
3
3
|
import { getRuntimeString } from './secrets';
|
|
4
4
|
import { getOrCreateSecret } from '../db/account';
|
|
5
|
+
import { InvalidToken, ServerMisconfigured } from './errors';
|
|
6
|
+
import { SignJWT, jwtVerify, type JWTPayload } from 'jose';
|
|
5
7
|
|
|
6
8
|
const SESSION_SECRET_KEY = 'session_jwt_secret';
|
|
7
9
|
const GRACE_PERIOD_SECONDS = 2 * 60 * 60;
|
|
@@ -25,10 +27,8 @@ async function getJwtKey(env: Env): Promise<Uint8Array> {
|
|
|
25
27
|
}
|
|
26
28
|
|
|
27
29
|
async function getServiceDid(env: Env): Promise<string> {
|
|
28
|
-
const did = await getRuntimeString(env, 'PDS_DID', '
|
|
29
|
-
if (!did)
|
|
30
|
-
throw new Error('PDS_DID is not configured');
|
|
31
|
-
}
|
|
30
|
+
const did = await getRuntimeString(env, 'PDS_DID', '');
|
|
31
|
+
if (!did) throw new ServerMisconfigured('PDS_DID is not configured');
|
|
32
32
|
return did;
|
|
33
33
|
}
|
|
34
34
|
|
|
@@ -72,10 +72,10 @@ export async function verifyRefreshToken(env: Env, token: string) {
|
|
|
72
72
|
const serviceDid = await getServiceDid(env);
|
|
73
73
|
const { header, payload } = await decodeAndVerifyJwt(key, token, 'refresh+jwt', serviceDid);
|
|
74
74
|
if (header.typ !== 'refresh+jwt') {
|
|
75
|
-
throw new
|
|
75
|
+
throw new InvalidToken('Invalid token type');
|
|
76
76
|
}
|
|
77
77
|
if (payload.scope !== 'refresh') {
|
|
78
|
-
throw new
|
|
78
|
+
throw new InvalidToken('Invalid refresh token scope');
|
|
79
79
|
}
|
|
80
80
|
return {
|
|
81
81
|
payload,
|
|
@@ -93,10 +93,10 @@ export async function verifyAccessToken(env: Env, token: string) {
|
|
|
93
93
|
const serviceDid = await getServiceDid(env);
|
|
94
94
|
const { header, payload } = await decodeAndVerifyJwt(key, token, 'at+jwt', serviceDid);
|
|
95
95
|
if (header.typ !== 'at+jwt') {
|
|
96
|
-
throw new
|
|
96
|
+
throw new InvalidToken('Invalid token type');
|
|
97
97
|
}
|
|
98
98
|
if (payload.scope === 'refresh') {
|
|
99
|
-
throw new
|
|
99
|
+
throw new InvalidToken('Unexpected scope for access token');
|
|
100
100
|
}
|
|
101
101
|
return payload;
|
|
102
102
|
}
|
|
@@ -121,82 +121,34 @@ type RefreshTokenPayload = TokenPayload & { jti: string };
|
|
|
121
121
|
type TokenHeader = { alg: 'HS256'; typ: 'at+jwt' | 'refresh+jwt' };
|
|
122
122
|
|
|
123
123
|
async function signJwt(key: Uint8Array, typ: TokenHeader['typ'], payload: TokenPayload): Promise<string> {
|
|
124
|
-
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
124
|
+
// jose will set standard claims via dedicated methods; we also keep custom claims in payload
|
|
125
|
+
const signer = new SignJWT(payload as JWTPayload)
|
|
126
|
+
.setProtectedHeader({ alg: 'HS256', typ })
|
|
127
|
+
.setSubject(payload.sub)
|
|
128
|
+
.setAudience(payload.aud)
|
|
129
|
+
.setIssuedAt(payload.iat)
|
|
130
|
+
.setExpirationTime(payload.exp);
|
|
131
|
+
return await signer.sign(key);
|
|
130
132
|
}
|
|
131
133
|
|
|
132
134
|
async function decodeAndVerifyJwt(key: Uint8Array, token: string, expectedTyp: TokenHeader['typ'], audience: string) {
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
if (header.alg !== 'HS256' || header.typ !== expectedTyp) {
|
|
141
|
-
throw new Error('Unexpected token header');
|
|
135
|
+
const { payload, protectedHeader } = await jwtVerify(token, key, {
|
|
136
|
+
algorithms: ['HS256'],
|
|
137
|
+
audience,
|
|
138
|
+
});
|
|
139
|
+
if (protectedHeader.typ !== expectedTyp) {
|
|
140
|
+
throw new InvalidToken('Unexpected token header');
|
|
142
141
|
}
|
|
143
|
-
if (payload.
|
|
144
|
-
throw new
|
|
145
|
-
}
|
|
146
|
-
if (!payload.sub) {
|
|
147
|
-
throw new Error('Token missing subject');
|
|
142
|
+
if (!payload.sub || typeof payload.sub !== 'string') {
|
|
143
|
+
throw new InvalidToken('Token missing subject');
|
|
148
144
|
}
|
|
149
145
|
if (typeof payload.exp !== 'number') {
|
|
150
|
-
throw new
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
const data = `${parts[0]}.${parts[1]}`;
|
|
154
|
-
const ok = await hmacVerify(key, data, parts[2]);
|
|
155
|
-
if (!ok) {
|
|
156
|
-
throw new Error('Invalid token signature');
|
|
146
|
+
throw new InvalidToken('Token missing expiry');
|
|
157
147
|
}
|
|
158
|
-
|
|
159
|
-
return { header, payload };
|
|
148
|
+
return { header: protectedHeader as TokenHeader, payload: payload as unknown as TokenPayload };
|
|
160
149
|
}
|
|
161
150
|
|
|
162
151
|
function generateTokenId(): string {
|
|
163
|
-
|
|
164
|
-
return base64UrlEncode(bytes);
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
async function hmacSign(keyBytes: Uint8Array, data: string): Promise<string> {
|
|
168
|
-
const cryptoKey = await crypto.subtle.importKey('raw', keyBytes, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
|
|
169
|
-
const signature = await crypto.subtle.sign('HMAC', cryptoKey, textEncoder.encode(data));
|
|
170
|
-
return base64UrlEncode(new Uint8Array(signature));
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
async function hmacVerify(keyBytes: Uint8Array, data: string, signatureB64: string): Promise<boolean> {
|
|
174
|
-
const cryptoKey = await crypto.subtle.importKey('raw', keyBytes, { name: 'HMAC', hash: 'SHA-256' }, false, ['verify']);
|
|
175
|
-
return crypto.subtle.verify('HMAC', cryptoKey, base64UrlDecodeToBytes(signatureB64), textEncoder.encode(data));
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
function base64UrlEncode(value: string | Uint8Array): string {
|
|
179
|
-
const bytes = typeof value === 'string' ? textEncoder.encode(value) : value;
|
|
180
|
-
let binary = '';
|
|
181
|
-
for (let i = 0; i < bytes.length; i++) {
|
|
182
|
-
binary += String.fromCharCode(bytes[i]);
|
|
183
|
-
}
|
|
184
|
-
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
function base64UrlDecode(encoded: string): string {
|
|
188
|
-
const pad = encoded.length % 4 === 2 ? '==' : encoded.length % 4 === 3 ? '=' : '';
|
|
189
|
-
const binary = atob(encoded.replace(/-/g, '+').replace(/_/g, '/') + pad);
|
|
190
|
-
return binary;
|
|
152
|
+
return bytesToHex(randomBytes(16));
|
|
191
153
|
}
|
|
192
|
-
|
|
193
|
-
function base64UrlDecodeToBytes(encoded: string): Uint8Array {
|
|
194
|
-
const binary = base64UrlDecode(encoded);
|
|
195
|
-
const bytes = new Uint8Array(binary.length);
|
|
196
|
-
for (let i = 0; i < binary.length; i++) {
|
|
197
|
-
bytes[i] = binary.charCodeAt(i);
|
|
198
|
-
}
|
|
199
|
-
return bytes;
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
const textEncoder = new TextEncoder();
|
|
154
|
+
// removed custom HMAC/base64url helpers in favor of jose
|
package/src/lib/streaming-car.ts
CHANGED
|
@@ -58,6 +58,9 @@ function concat(parts: Uint8Array[]): Uint8Array {
|
|
|
58
58
|
* });
|
|
59
59
|
* ```
|
|
60
60
|
*/
|
|
61
|
+
// StreamingCarEncoder is a class because it caches the encoded header
|
|
62
|
+
// once and then writes one block at a time; the cache state plus the
|
|
63
|
+
// per-block writeBlock() method belong on the same object.
|
|
61
64
|
export class StreamingCarEncoder {
|
|
62
65
|
private headerBytes: Uint8Array;
|
|
63
66
|
|
package/src/lib/tracing.ts
CHANGED
|
@@ -12,9 +12,10 @@ export interface TraceSpan {
|
|
|
12
12
|
labels?: Record<string, string>;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
// Tracer is a class because each instance owns a Map of in-flight spans;
|
|
16
|
+
// start() and end() are paired stateful calls that need shared access to
|
|
17
|
+
// that map. The global `tracer` plus per-request instances both rely on
|
|
18
|
+
// this isolation.
|
|
18
19
|
export class Tracer {
|
|
19
20
|
private spans: Map<string, TraceSpan> = new Map();
|
|
20
21
|
|