+1
.gitignore
+1
.gitignore
+5
-2
package.json
+5
-2
package.json
···
20
20
"@atcute/identity": "^1.1.1",
21
21
"@atcute/identity-resolver": "^1.1.4",
22
22
"@atcute/lexicons": "^1.2.2",
23
-
"@atcute/oauth-browser-client": "2.0.0",
23
+
"@atcute/multibase": "^1.1.6",
24
+
"@atcute/oauth-browser-client": "2.0.1",
24
25
"@atcute/tid": "^1.0.3",
26
+
"@atcute/uint8array": "^1.0.5",
25
27
"@atcute/xrpc-server": "^0.1.3",
26
28
"@atlaskit/pragmatic-drag-and-drop": "1.6.0",
27
29
"@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3",
···
45
47
"webm-muxer": "^5.1.4"
46
48
},
47
49
"devDependencies": {
50
+
"@badrap/valita": "^0.4.6",
48
51
"@cloudflare/vite-plugin": "^1.13.15",
49
52
"@trivago/prettier-plugin-sort-imports": "^5.2.2",
50
53
"@types/dom-close-watcher": "^1.0.0",
···
58
61
"terser": "^5.44.0",
59
62
"typescript": "~5.9.3",
60
63
"vite": "^7.1.12",
61
-
"vite-plugin-pwa": "0.21.0",
64
+
"vite-plugin-pwa": "1.1.0",
62
65
"vite-plugin-solid": "^2.11.10",
63
66
"wrangler": "^4.45.0"
64
67
},
+21
-12
pnpm-lock.yaml
+21
-12
pnpm-lock.yaml
···
66
66
'@atcute/lexicons':
67
67
specifier: ^1.2.2
68
68
version: 1.2.2
69
+
'@atcute/multibase':
70
+
specifier: ^1.1.6
71
+
version: 1.1.6
69
72
'@atcute/oauth-browser-client':
70
-
specifier: 2.0.0
71
-
version: 2.0.0
73
+
specifier: 2.0.1
74
+
version: 2.0.1
72
75
'@atcute/tid':
73
76
specifier: ^1.0.3
74
77
version: 1.0.3
78
+
'@atcute/uint8array':
79
+
specifier: ^1.0.5
80
+
version: 1.0.5
75
81
'@atcute/xrpc-server':
76
82
specifier: ^0.1.3
77
83
version: 0.1.3
···
136
142
specifier: ^5.1.4
137
143
version: 5.1.4
138
144
devDependencies:
145
+
'@badrap/valita':
146
+
specifier: ^0.4.6
147
+
version: 0.4.6
139
148
'@cloudflare/vite-plugin':
140
149
specifier: ^1.13.15
141
150
version: 1.13.15(vite@7.1.12(@types/node@24.9.1)(jiti@1.21.7)(terser@5.44.0))(workerd@1.20251011.0)(wrangler@4.45.0)
···
176
185
specifier: ^7.1.12
177
186
version: 7.1.12(@types/node@24.9.1)(jiti@1.21.7)(terser@5.44.0)
178
187
vite-plugin-pwa:
179
-
specifier: 0.21.0
180
-
version: 0.21.0(patch_hash=003379ded749ad87080f87b428b17f04c4c88b6b64544df4d928aab76fbf6325)(@types/babel__core@7.20.5)(vite@7.1.12(@types/node@24.9.1)(jiti@1.21.7)(terser@5.44.0))
188
+
specifier: 1.1.0
189
+
version: 1.1.0(patch_hash=003379ded749ad87080f87b428b17f04c4c88b6b64544df4d928aab76fbf6325)(@types/babel__core@7.20.5)(vite@7.1.12(@types/node@24.9.1)(jiti@1.21.7)(terser@5.44.0))
181
190
vite-plugin-solid:
182
191
specifier: ^2.11.10
183
192
version: 2.11.10(solid-js@1.9.9(patch_hash=9cf3f9930aa2f8d4e60502a75153adf9468eb53b42f69e86cac05dfaea3f82e7))(vite@7.1.12(@types/node@24.9.1)(jiti@1.21.7)(terser@5.44.0))
···
241
250
'@atcute/multibase@1.1.6':
242
251
resolution: {integrity: sha512-HBxuCgYLKPPxETV0Rot4VP9e24vKl8JdzGCZOVsDaOXJgbRZoRIF67Lp0H/OgnJeH/Xpva8Z5ReoTNJE5dn3kg==}
243
252
244
-
'@atcute/oauth-browser-client@2.0.0':
245
-
resolution: {integrity: sha512-zK4wcQ79g9EqU7NJk/Wxx9ImOH6UwhpEcazDA8bimUJhl9pwXxMC0u0x/tgzFjJZYgwnOHoBMwLmhl0900hcCQ==}
253
+
'@atcute/oauth-browser-client@2.0.1':
254
+
resolution: {integrity: sha512-lG021GkeORG06zfFf4bH85egObjBEKHNgAWHvbtY/E2dX4wxo88hf370pJDx8acdnuUJLJ2VKPikJtZwo4Heeg==}
246
255
247
256
'@atcute/tid@1.0.3':
248
257
resolution: {integrity: sha512-wfMJx1IMdnu0CZgWl0uR4JO2s6PGT1YPhpytD4ZHzEYKKQVuqV6Eb/7vieaVo1eYNMp2FrY67FZObeR7utRl2w==}
···
2604
2613
validate-html-nesting@1.2.3:
2605
2614
resolution: {integrity: sha512-kdkWdCl6eCeLlRShJKbjVOU2kFKxMF8Ghu50n+crEoyx+VKm3FxAxF9z4DCy6+bbTOqNW0+jcIYRnjoIRzigRw==}
2606
2615
2607
-
vite-plugin-pwa@0.21.0:
2608
-
resolution: {integrity: sha512-gnDE5sN2hdxA4vTl0pe6PCTPXqChk175jH8dZVVTBjFhWarZZoXaAdoTIKCIa8Zbx94sC0CnCOyERBWpxvry+g==}
2616
+
vite-plugin-pwa@1.1.0:
2617
+
resolution: {integrity: sha512-VsSpdubPzXhHWVINcSx6uHRMpOHVHQcHsef1QgkOlEoaIDAlssFEW88LBq1a59BuokAhsh2kUDJbaX1bZv4Bjw==}
2609
2618
engines: {node: '>=16.0.0'}
2610
2619
peerDependencies:
2611
-
'@vite-pwa/assets-generator': ^0.2.6
2612
-
vite: ^3.1.0 || ^4.0.0 || ^5.0.0
2620
+
'@vite-pwa/assets-generator': ^1.0.0
2621
+
vite: ^3.1.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
2613
2622
peerDependenciesMeta:
2614
2623
'@vite-pwa/assets-generator':
2615
2624
optional: true
···
2864
2873
dependencies:
2865
2874
'@atcute/uint8array': 1.0.5
2866
2875
2867
-
'@atcute/oauth-browser-client@2.0.0':
2876
+
'@atcute/oauth-browser-client@2.0.1':
2868
2877
dependencies:
2869
2878
'@atcute/client': 4.0.5
2870
2879
'@atcute/identity': 1.1.1
···
5109
5118
5110
5119
validate-html-nesting@1.2.3: {}
5111
5120
5112
-
vite-plugin-pwa@0.21.0(patch_hash=003379ded749ad87080f87b428b17f04c4c88b6b64544df4d928aab76fbf6325)(@types/babel__core@7.20.5)(vite@7.1.12(@types/node@24.9.1)(jiti@1.21.7)(terser@5.44.0)):
5121
+
vite-plugin-pwa@1.1.0(patch_hash=003379ded749ad87080f87b428b17f04c4c88b6b64544df4d928aab76fbf6325)(@types/babel__core@7.20.5)(vite@7.1.12(@types/node@24.9.1)(jiti@1.21.7)(terser@5.44.0)):
5113
5122
dependencies:
5114
5123
debug: 4.4.3
5115
5124
pretty-bytes: 6.1.1
-12
public/oauth-client-metadata.json
-12
public/oauth-client-metadata.json
···
1
-
{
2
-
"client_id": "https://aglais.kelinci.net/oauth-client-metadata.json",
3
-
"client_uri": "https://aglais.kelinci.net",
4
-
"client_name": "Aglais",
5
-
"application_type": "web",
6
-
"scope": "atproto transition:generic transition:chat.bsky",
7
-
"grant_types": ["authorization_code", "refresh_token"],
8
-
"redirect_uris": ["https://aglais.kelinci.net/oauth/callback"],
9
-
"response_types": ["code"],
10
-
"token_endpoint_auth_method": "none",
11
-
"dpop_bound_access_tokens": true
12
-
}
+67
scripts/generate-oauth-keys.js
+67
scripts/generate-oauth-keys.js
···
1
+
import * as fs from 'node:fs/promises';
2
+
3
+
import * as v from '@badrap/valita';
4
+
5
+
import * as TID from '@atcute/tid';
6
+
7
+
const jwksSchema = v.object({
8
+
keys: v.array(
9
+
v.object({
10
+
privateKey: v.unknown(),
11
+
publicKey: v.unknown(),
12
+
}),
13
+
),
14
+
});
15
+
16
+
/** @type {v.Infer<typeof jwksSchema> | undefined} */
17
+
let jwks;
18
+
try {
19
+
const raw = await fs.readFile('./oauth-credentials.local.json', 'utf-8');
20
+
const json = JSON.parse(raw);
21
+
22
+
jwks = jwksSchema.parse(json, { mode: 'passthrough' });
23
+
} catch (err) {
24
+
if (err.code !== 'ENOENT') {
25
+
throw err;
26
+
}
27
+
28
+
jwks = {
29
+
keys: [],
30
+
};
31
+
}
32
+
33
+
const { publicKey, privateKey } = await crypto.subtle.generateKey(
34
+
{
35
+
name: 'ECDSA',
36
+
namedCurve: 'P-256',
37
+
},
38
+
true,
39
+
['sign', 'verify'],
40
+
);
41
+
42
+
const kid = `aglais-${TID.now()}`;
43
+
const privateJWK = await crypto.subtle.exportKey('jwk', privateKey);
44
+
const publicJWK = await crypto.subtle.exportKey('jwk', publicKey);
45
+
46
+
jwks = {
47
+
keys: [
48
+
{
49
+
privateKey: {
50
+
...privateJWK,
51
+
kid: kid,
52
+
},
53
+
publicKey: {
54
+
kty: publicJWK.kty,
55
+
crv: publicJWK.crv,
56
+
x: publicJWK.x,
57
+
y: publicJWK.y,
58
+
use: 'sig',
59
+
alg: 'ES256',
60
+
kid: kid,
61
+
},
62
+
},
63
+
...jwks.keys,
64
+
],
65
+
};
66
+
67
+
await fs.writeFile('./oauth-credentials.local.json', JSON.stringify(jwks, null, '\t') + '\n');
+128
-30
server/index.ts
+128
-30
server/index.ts
···
1
-
import { ComAtprotoIdentityResolveDid, ComAtprotoIdentityResolveHandle } from '@atcute/atproto';
1
+
import { type DidDocument, getAtprotoHandle, getPdsEndpoint } from '@atcute/identity';
2
2
import {
3
3
AmbiguousHandleError,
4
4
CompositeDidDocumentResolver,
···
13
13
WebDidDocumentResolver,
14
14
WellKnownHandleResolver,
15
15
} from '@atcute/identity-resolver';
16
-
import { InvalidRequestError, XRPCRouter, json } from '@atcute/xrpc-server';
16
+
import { type Did, type Handle, type ResourceUri, isDid } from '@atcute/lexicons/syntax';
17
+
import { AuthRequiredError, InvalidRequestError, XRPCRouter, json } from '@atcute/xrpc-server';
18
+
19
+
import * as jwks from '../oauth-credentials.local.json' with { type: 'json' };
20
+
21
+
import { InvalidDPoPError, createClientAssertion, verifyDPoP } from './jwt';
22
+
import { requestAssertionSchema, resolveIdentitySchema } from './lexicons';
23
+
24
+
const privateKeyId = jwks.keys[0].privateKey.kid;
25
+
const privateKey = await crypto.subtle.importKey(
26
+
'jwk',
27
+
jwks.keys[0].privateKey,
28
+
{ name: 'ECDSA', namedCurve: 'P-256' },
29
+
false,
30
+
['sign'],
31
+
);
17
32
18
33
const handleResolver = new CompositeHandleResolver({
19
34
methods: {
···
22
37
},
23
38
});
24
39
25
-
const didDocResolver = new CompositeDidDocumentResolver<string>({
40
+
const didDocumentResolver = new CompositeDidDocumentResolver<string>({
26
41
methods: {
27
42
plc: new PlcDidDocumentResolver(),
28
43
web: new WebDidDocumentResolver(),
···
35
50
const router = new XRPCRouter({
36
51
middlewares: [
37
52
async (request, next) => {
53
+
if (request.method !== 'GET') {
54
+
return await next(request);
55
+
}
56
+
38
57
let response = await cache.match(request);
39
58
if (response === undefined) {
40
59
response = await next(request);
···
54
73
],
55
74
});
56
75
57
-
router.add(ComAtprotoIdentityResolveHandle.mainSchema, {
58
-
async handler({ params: { handle } }) {
59
-
try {
60
-
const did = await handleResolver.resolve(handle);
76
+
router.addProcedure(requestAssertionSchema, {
77
+
async handler({ input: { jkt, aud }, request }) {
78
+
const url = new URL(request.url);
61
79
62
-
return json({ did }, { headers: { 'cache-control': 'public, max-age=600' } });
80
+
const origin = request.headers.get('origin');
81
+
if (origin !== url.origin) {
82
+
throw new AuthRequiredError({ description: 'invalid origin' });
83
+
}
84
+
85
+
const dpop = request.headers.get('dpop');
86
+
try {
87
+
await verifyDPoP(dpop, jkt);
63
88
} catch (err) {
64
-
console.error(`resolveHandleToDid`, handle, err);
65
-
66
-
if (err instanceof DidNotFoundError) {
67
-
throw new InvalidRequestError({ description: `no did found under that handle` });
89
+
if (err instanceof InvalidDPoPError) {
90
+
throw new AuthRequiredError({ description: err.message });
68
91
}
69
92
70
-
if (err instanceof InvalidResolvedHandleError) {
71
-
throw new InvalidRequestError({ description: `did found but is invalid atproto did` });
72
-
}
93
+
throw err;
94
+
}
73
95
74
-
if (err instanceof AmbiguousHandleError) {
75
-
throw new InvalidRequestError({ description: `multiple did found under that handle` });
76
-
}
96
+
const assertion = await createClientAssertion({
97
+
privateKey: privateKey,
98
+
99
+
client_id: `https://${url.host}/oauth-client-metadata.json`,
100
+
kid: privateKeyId,
101
+
aud: aud,
102
+
});
77
103
78
-
throw err;
79
-
}
104
+
return json({
105
+
assertion: assertion,
106
+
});
80
107
},
81
108
});
82
109
83
-
router.add(ComAtprotoIdentityResolveDid.mainSchema, {
84
-
async handler({ params: { did } }) {
85
-
try {
86
-
const doc = await didDocResolver.resolve(did);
110
+
router.addQuery(resolveIdentitySchema, {
111
+
async handler({ params: { identifier } }) {
112
+
const identifierIsDid = isDid(identifier);
87
113
88
-
return json(
89
-
{ didDoc: doc as unknown as Record<string, unknown> },
90
-
{ headers: { 'cache-control': 'public, max-age=3600' } },
91
-
);
92
-
} catch (err) {
93
-
console.error(`resolveDidToDoc`, did, err);
114
+
let did: Did;
115
+
if (identifierIsDid) {
116
+
did = identifier;
117
+
} else {
118
+
try {
119
+
did = await handleResolver.resolve(identifier);
120
+
} catch (err) {
121
+
if (err instanceof DidNotFoundError) {
122
+
throw new InvalidRequestError({ description: `no did found under that handle` });
123
+
}
94
124
125
+
if (err instanceof InvalidResolvedHandleError) {
126
+
throw new InvalidRequestError({ description: `did found but is invalid atproto did` });
127
+
}
128
+
129
+
if (err instanceof AmbiguousHandleError) {
130
+
throw new InvalidRequestError({ description: `multiple did found under that handle` });
131
+
}
132
+
133
+
throw err;
134
+
}
135
+
}
136
+
137
+
let doc: DidDocument;
138
+
try {
139
+
doc = await didDocumentResolver.resolve(did);
140
+
} catch (err) {
95
141
if (err instanceof DocumentNotFoundError) {
96
142
throw new InvalidRequestError({ description: `no document found under that did` });
97
143
}
···
106
152
107
153
throw err;
108
154
}
155
+
156
+
const pds = getPdsEndpoint(doc);
157
+
if (!pds) {
158
+
throw new InvalidRequestError({ description: `missing pds endpoint` });
159
+
}
160
+
161
+
let handle: Handle = 'handle.invalid';
162
+
if (identifierIsDid) {
163
+
const writtenHandle = getAtprotoHandle(doc);
164
+
if (writtenHandle) {
165
+
try {
166
+
const resolved = await handleResolver.resolve(writtenHandle);
167
+
168
+
if (resolved === did) {
169
+
handle = writtenHandle;
170
+
}
171
+
} catch {}
172
+
}
173
+
} else if (getAtprotoHandle(doc) === identifier) {
174
+
handle = identifier;
175
+
}
176
+
177
+
return json({
178
+
did: did,
179
+
handle: handle,
180
+
pds: new URL(pds).href as ResourceUri,
181
+
});
109
182
},
110
183
});
111
184
112
185
export default {
113
186
fetch(request, _env, ctx) {
187
+
const url = new URL(request.url);
188
+
189
+
if (url.pathname === '/oauth-client-metadata.json') {
190
+
return Response.json({
191
+
client_id: `https://${url.host}/oauth-client-metadata.json`,
192
+
client_uri: `https://${url.host}`,
193
+
client_name: import.meta.env.VITE_APP_NAME,
194
+
application_type: 'web',
195
+
scope: 'atproto transition:generic transition:chat.bsky',
196
+
grant_types: ['authorization_code', 'refresh_token'],
197
+
redirect_uris: [`https://${url.host}/oauth/callback`],
198
+
response_types: ['code'],
199
+
token_endpoint_auth_method: 'private_key_jwt',
200
+
token_endpoint_auth_signing_alg: 'ES256',
201
+
jwks_uri: `https://${url.host}/oauth-jwks.json`,
202
+
dpop_bound_access_tokens: true,
203
+
});
204
+
}
205
+
206
+
if (url.pathname === '/oauth-jwks.json') {
207
+
return Response.json({
208
+
keys: jwks.keys.map((key) => key.publicKey),
209
+
});
210
+
}
211
+
114
212
contexts.set(request, ctx);
115
213
return router.fetch(request);
116
214
},
+206
server/jwt.ts
+206
server/jwt.ts
···
1
+
import * as v from '@badrap/valita';
2
+
3
+
import { fromBase64Url, toBase64Url } from '@atcute/multibase';
4
+
import { decodeUtf8From, encodeUtf8 } from '@atcute/uint8array';
5
+
6
+
export class MalformedJwtError extends Error {
7
+
name = 'MalformedJwtError';
8
+
9
+
constructor(options?: ErrorOptions) {
10
+
super(`malformed JWT`, options);
11
+
}
12
+
}
13
+
14
+
export interface DecodedJwt<THeader, TPayload> {
15
+
header: THeader;
16
+
payload: TPayload;
17
+
message: Uint8Array<ArrayBuffer>;
18
+
signature: Uint8Array<ArrayBuffer>;
19
+
}
20
+
21
+
const decodeJwt = <THeader, TPayload>(
22
+
input: string,
23
+
schemas: { header: v.Type<THeader>; payload: v.Type<TPayload> },
24
+
): DecodedJwt<THeader, TPayload> => {
25
+
const parts = input.split('.');
26
+
if (parts.length !== 3) {
27
+
throw new MalformedJwtError();
28
+
}
29
+
30
+
const [headerString, payloadString, signatureString] = parts;
31
+
32
+
const header = decodeJwtPortion(schemas.header, headerString);
33
+
const payload = decodeJwtPortion(schemas.payload, payloadString);
34
+
const signature = decodeJwtSignature(signatureString);
35
+
36
+
return {
37
+
header: header,
38
+
payload: payload,
39
+
message: encodeUtf8(`${headerString}.${payloadString}`),
40
+
signature: signature,
41
+
};
42
+
};
43
+
44
+
const decodeJwtPortion = <T>(schema: v.Type<T>, input: string): T => {
45
+
try {
46
+
const raw = decodeUtf8From(fromBase64Url(input));
47
+
const json = JSON.parse(raw);
48
+
49
+
return schema.parse(json, { mode: 'passthrough' });
50
+
} catch (err) {
51
+
throw new MalformedJwtError({ cause: err });
52
+
}
53
+
};
54
+
55
+
const decodeJwtSignature = (input: string): Uint8Array<ArrayBuffer> => {
56
+
try {
57
+
return fromBase64Url(input);
58
+
} catch (err) {
59
+
throw new MalformedJwtError({ cause: err });
60
+
}
61
+
};
62
+
63
+
const encodeJwtPortion = (data: unknown): string => {
64
+
return toBase64Url(encodeUtf8(JSON.stringify(data)));
65
+
};
66
+
67
+
const encodeJwtSignature = (data: Uint8Array): string => {
68
+
return toBase64Url(data);
69
+
};
70
+
71
+
// #region DPoP
72
+
export class InvalidDPoPError extends Error {
73
+
name = 'InvalidDPoPError';
74
+
}
75
+
76
+
const dpopHeaderSchema = v.object({
77
+
typ: v.literal('dpop+jwt'),
78
+
alg: v.literal('ES256'),
79
+
jwk: v.object({
80
+
kty: v.literal('EC'),
81
+
crv: v.literal('P-256'),
82
+
x: v.string(),
83
+
y: v.string(),
84
+
}),
85
+
});
86
+
87
+
const dpopPayloadSchema = v.object({
88
+
htm: v.string(),
89
+
htu: v.string(),
90
+
iat: v.number(),
91
+
jti: v.string(),
92
+
});
93
+
94
+
const calculateJwkThumbprint = async (jwk: JsonWebKey): Promise<string> => {
95
+
// For EC keys, thumbprint is SHA-256 of canonical JSON
96
+
// Members must be in lexicographic order
97
+
const canonical = JSON.stringify({
98
+
crv: jwk.crv,
99
+
kty: jwk.kty,
100
+
x: jwk.x,
101
+
y: jwk.y,
102
+
});
103
+
104
+
const hash = await crypto.subtle.digest('SHA-256', encodeUtf8(canonical));
105
+
return toBase64Url(new Uint8Array(hash));
106
+
};
107
+
108
+
export const verifyDPoP = async (dpop: string | null, jkt: string): Promise<void> => {
109
+
if (!dpop) {
110
+
throw new InvalidDPoPError(`missing DPoP header`);
111
+
}
112
+
113
+
// Decode the DPoP JWT
114
+
let decoded;
115
+
try {
116
+
decoded = decodeJwt(dpop, {
117
+
header: dpopHeaderSchema,
118
+
payload: dpopPayloadSchema,
119
+
});
120
+
} catch (err) {
121
+
throw new InvalidDPoPError(`malformed JWT`, { cause: err });
122
+
}
123
+
124
+
const { header, message, signature } = decoded;
125
+
126
+
// Verify JWK thumbprint matches jkt
127
+
const thumbprint = await calculateJwkThumbprint(header.jwk);
128
+
if (thumbprint !== jkt) {
129
+
throw new InvalidDPoPError(`JWK thumbprint mismatch`);
130
+
}
131
+
132
+
// Import the public key for signature verification
133
+
let publicKey: CryptoKey;
134
+
try {
135
+
publicKey = await crypto.subtle.importKey(
136
+
'jwk',
137
+
header.jwk,
138
+
{ name: 'ECDSA', namedCurve: 'P-256' },
139
+
false,
140
+
['verify'],
141
+
);
142
+
} catch (err) {
143
+
throw new InvalidDPoPError(`failed to import JWK`, { cause: err });
144
+
}
145
+
146
+
// Verify the signature
147
+
const isValid = await crypto.subtle.verify(
148
+
{ name: 'ECDSA', hash: 'SHA-256' },
149
+
publicKey,
150
+
signature,
151
+
message,
152
+
);
153
+
154
+
if (!isValid) {
155
+
throw new InvalidDPoPError(`invalid DPoP signature`);
156
+
}
157
+
};
158
+
159
+
// #endregion
160
+
161
+
// #region Client assertions
162
+
163
+
export const createClientAssertion = async (options: {
164
+
kid: string;
165
+
client_id: string;
166
+
aud: string;
167
+
privateKey: CryptoKey;
168
+
}): Promise<string> => {
169
+
const { kid, client_id, aud, privateKey } = options;
170
+
171
+
const now = Math.floor(Date.now() / 1000);
172
+
173
+
const header = {
174
+
alg: 'ES256',
175
+
typ: 'JWT',
176
+
kid: kid,
177
+
};
178
+
179
+
const payload = {
180
+
iss: client_id,
181
+
sub: client_id,
182
+
aud: aud,
183
+
jti: crypto.randomUUID(),
184
+
iat: now,
185
+
exp: now + 60,
186
+
};
187
+
188
+
const message = `${encodeJwtPortion(header)}.${encodeJwtPortion(payload)}`;
189
+
190
+
const signature = encodeJwtSignature(
191
+
new Uint8Array(
192
+
await crypto.subtle.sign(
193
+
{
194
+
name: 'ECDSA',
195
+
hash: 'SHA-256',
196
+
},
197
+
privateKey,
198
+
encodeUtf8(message),
199
+
),
200
+
),
201
+
);
202
+
203
+
return `${message}.${signature}`;
204
+
};
205
+
206
+
// #endregion
+43
server/lexicons.ts
+43
server/lexicons.ts
···
1
+
import type {} from '@atcute/lexicons/ambient';
2
+
import * as v from '@atcute/lexicons/validations';
3
+
4
+
export const requestAssertionSchema = v.procedure('x.aglais.requestAssertion', {
5
+
params: null,
6
+
input: {
7
+
type: 'lex',
8
+
schema: v.object({
9
+
jkt: v.string(),
10
+
aud: v.string(),
11
+
}),
12
+
},
13
+
output: {
14
+
type: 'lex',
15
+
schema: v.object({
16
+
assertion: v.string(),
17
+
}),
18
+
},
19
+
});
20
+
21
+
export const resolveIdentitySchema = v.query('x.aglais.resolveIdentity', {
22
+
params: v.object({
23
+
identifier: v.actorIdentifierString(),
24
+
}),
25
+
output: {
26
+
type: 'lex',
27
+
schema: v.object({
28
+
did: v.didString(),
29
+
handle: v.handleString(),
30
+
pds: v.genericUriString(),
31
+
}),
32
+
},
33
+
});
34
+
35
+
declare module '@atcute/lexicons/ambient' {
36
+
interface XRPCProcedures {
37
+
'x.aglais.requestAssertion': typeof requestAssertionSchema;
38
+
}
39
+
40
+
interface XRPCQueries {
41
+
'x.aglais.resolveIdentity': typeof resolveIdentitySchema;
42
+
}
43
+
}
+9
server/vite-env.d.ts
+9
server/vite-env.d.ts
+1
-1
src/components/main/sign-in-dialog.tsx
+1
-1
src/components/main/sign-in-dialog.tsx
+37
-21
src/main.tsx
+37
-21
src/main.tsx
···
2
2
import { type JSX, createSignal, onMount } from 'solid-js';
3
3
import { render } from 'solid-js/web';
4
4
5
-
import type { Did, Handle } from '@atcute/lexicons';
5
+
import { Client, ok, simpleFetchHandler } from '@atcute/client';
6
+
import type { Did } from '@atcute/lexicons';
6
7
import { configureOAuth } from '@atcute/oauth-browser-client';
7
8
8
9
import * as navigation from '~/globals/navigation';
···
18
19
import CircularProgress from '~/components/circular-progress';
19
20
import ModalRenderer from '~/components/main/modal-renderer';
20
21
22
+
import type {} from '../server/lexicons';
23
+
21
24
import routes from './routes';
22
25
import './service-worker';
23
26
import Shell from './shell';
···
33
36
34
37
// Configure OAuth
35
38
{
39
+
const host = new Client({
40
+
handler: simpleFetchHandler({ service: location.origin }),
41
+
});
42
+
36
43
configureOAuth({
37
44
metadata: {
38
-
client_id: import.meta.env.VITE_OAUTH_CLIENT_ID,
39
-
redirect_uri: import.meta.env.VITE_OAUTH_REDIRECT_URL,
45
+
client_id: `${location.origin}/oauth-client-metadata.json`,
46
+
redirect_uri: `${location.origin}/oauth/callback`,
40
47
},
41
48
42
49
identityResolver: {
43
50
async resolve(actor) {
44
-
const url = new URL('https://slingshot.microcosm.blue/xrpc/com.bad-example.identity.resolveMiniDoc');
45
-
url.searchParams.set('identifier', actor);
51
+
const data = await ok(
52
+
host.get('x.aglais.resolveIdentity', {
53
+
params: {
54
+
identifier: actor,
55
+
},
56
+
}),
57
+
);
46
58
47
-
const response = await fetch(url);
48
-
if (!response.ok) {
49
-
throw new Error(`resolver responded with status ${response.status}`);
50
-
}
59
+
return data;
60
+
},
61
+
},
62
+
async fetchClientAssertion({ aud, jkt, createDpopProof }) {
63
+
const dpop = await createDpopProof(`${location.origin}/xrpc/x.aglais.requestAssertion`);
51
64
52
-
const json = (await response.json()) as {
53
-
did: Did;
54
-
handle: Handle;
55
-
pds: string;
56
-
signing_key: string;
57
-
};
65
+
const data = await ok(
66
+
host.post('x.aglais.requestAssertion', {
67
+
input: {
68
+
aud: aud,
69
+
jkt: jkt,
70
+
},
71
+
headers: {
72
+
dpop: dpop,
73
+
},
74
+
}),
75
+
);
58
76
59
-
return {
60
-
did: json.did,
61
-
handle: json.handle,
62
-
pds: json.pds,
63
-
};
64
-
},
77
+
return {
78
+
client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
79
+
client_assertion: data.assertion,
80
+
};
65
81
},
66
82
});
67
83
}
-6
src/vite-env.d.ts
-6
src/vite-env.d.ts
···
6
6
7
7
interface ImportMetaEnv {
8
8
readonly VITE_APP_NAME: string;
9
-
10
-
readonly VITE_DEV_SERVER_PORT?: string;
11
-
readonly VITE_CLIENT_URI: string;
12
-
readonly VITE_OAUTH_CLIENT_ID: string;
13
-
readonly VITE_OAUTH_REDIRECT_URL: string;
14
-
readonly VITE_OAUTH_SCOPE: string;
15
9
}
16
10
17
11
interface ImportMeta {
+2
-36
vite.config.ts
+2
-36
vite.config.ts
···
5
5
import { VitePWA } from 'vite-plugin-pwa';
6
6
import solid from 'vite-plugin-solid';
7
7
8
-
import metadata from './public/oauth-client-metadata.json';
9
-
10
-
const SERVER_HOST = '127.0.0.1';
11
-
const SERVER_PORT = 52222;
12
-
13
8
export default defineConfig({
14
9
build: {
15
10
target: 'esnext',
···
59
54
},
60
55
},
61
56
server: {
62
-
host: SERVER_HOST,
63
-
port: SERVER_PORT,
57
+
allowedHosts: ['.trycloudflare.com'],
64
58
},
65
59
optimizeDeps: {
66
60
esbuildOptions: {
···
74
68
},
75
69
}),
76
70
77
-
process.env.NODE_ENV === 'development' && cloudflare(),
71
+
cloudflare(),
78
72
79
73
VitePWA({
80
74
registerType: 'prompt',
···
116
110
);
117
111
118
112
return { code: transformed, map: null };
119
-
},
120
-
},
121
-
122
-
// Injects OAuth-related variables
123
-
{
124
-
name: 'aglais-oauth-inject',
125
-
config(_conf, { command }) {
126
-
if (command === 'build') {
127
-
process.env.VITE_OAUTH_CLIENT_ID = metadata.client_id;
128
-
process.env.VITE_OAUTH_REDIRECT_URL = metadata.redirect_uris[0];
129
-
} else {
130
-
const redirectUri = (() => {
131
-
const url = new URL(metadata.redirect_uris[0]);
132
-
return `http://${SERVER_HOST}:${SERVER_PORT}${url.pathname}`;
133
-
})();
134
-
135
-
const clientId =
136
-
`http://localhost` +
137
-
`?redirect_uri=${encodeURIComponent(redirectUri)}` +
138
-
`&scope=${encodeURIComponent(metadata.scope)}`;
139
-
140
-
process.env.VITE_DEV_SERVER_PORT = '' + SERVER_PORT;
141
-
process.env.VITE_OAUTH_CLIENT_ID = clientId;
142
-
process.env.VITE_OAUTH_REDIRECT_URL = redirectUri;
143
-
}
144
-
145
-
process.env.VITE_CLIENT_URI = metadata.client_uri;
146
-
process.env.VITE_OAUTH_SCOPE = metadata.scope;
147
113
},
148
114
},
149
115
],
+1
-2
wrangler.jsonc
+1
-2
wrangler.jsonc
···
4
4
"compatibility_date": "2025-08-16",
5
5
"main": "server/index.ts",
6
6
"assets": {
7
-
"directory": "dist",
8
7
"not_found_handling": "single-page-application",
9
-
"run_worker_first": ["/xrpc/*"],
8
+
"run_worker_first": ["/xrpc/*", "/oauth-client-metadata.json", "/oauth-jwks.json"],
10
9
},
11
10
}