tangled
alpha
login
or
join now
roost.moe
/
recipes.blue
2
fork
atom
The recipes.blue monorepo
recipes.blue
recipes
appview
atproto
2
fork
atom
overview
issues
1
pulls
pipelines
feat: working on implementing auth verification via proxy
Hayden Young
1 year ago
2862c8fd
4c9d4e0b
+348
-281
10 changed files
expand all
collapse all
unified
split
apps
api
package.json
src
auth
client.ts
index.ts
storage.ts
index.ts
recipes
index.ts
util
api.ts
jwt.ts
xrpc.ts
pnpm-lock.yaml
+2
apps/api/package.json
···
16
16
"@atcute/client": "^2.0.6",
17
17
"@atproto/api": "^0.13.19",
18
18
"@atproto/common": "^0.4.5",
19
19
+
"@atproto/crypto": "^0.4.2",
19
20
"@atproto/jwk-jose": "^0.1.2",
20
21
"@atproto/oauth-client-node": "^0.2.3",
21
22
"@cookware/database": "workspace:^",
···
29
30
"hono-sessions": "^0.7.0",
30
31
"jose": "^5.9.6",
31
32
"pino": "^9.5.0",
33
33
+
"uint8arrays": "^5.1.0",
32
34
"ws": "^8.18.0",
33
35
"zod": "^3.23.8"
34
36
},
-34
apps/api/src/auth/client.ts
···
1
1
-
import { JoseKey } from "@atproto/jwk-jose";
2
2
-
import { NodeOAuthClient } from "@atproto/oauth-client-node";
3
3
-
import env from "../config/env.js";
4
4
-
import { SessionStore, StateStore } from "./storage.js";
5
5
-
import { Context } from "hono";
6
6
-
7
7
-
export const getClient = async (ctx: Context) => {
8
8
-
let appUrl = 'https://recipes.blue';
9
9
-
if (env.ENV == 'development') {
10
10
-
appUrl = `https://${ctx.req.header('Host')}`;
11
11
-
}
12
12
-
13
13
-
return new NodeOAuthClient({
14
14
-
clientMetadata: {
15
15
-
client_id: `${appUrl}/oauth/client-metadata.json`,
16
16
-
client_name: 'Cookware',
17
17
-
client_uri: appUrl,
18
18
-
redirect_uris: [`${appUrl}/oauth/callback`],
19
19
-
response_types: ['code'],
20
20
-
application_type: 'web',
21
21
-
grant_types: ['authorization_code', 'refresh_token'],
22
22
-
scope: 'atproto transition:generic',
23
23
-
token_endpoint_auth_method: 'private_key_jwt',
24
24
-
token_endpoint_auth_signing_alg: 'ES256',
25
25
-
dpop_bound_access_tokens: true,
26
26
-
jwks_uri: `${appUrl}/oauth/jwks.json`,
27
27
-
},
28
28
-
keyset: await Promise.all([
29
29
-
JoseKey.fromImportable(env.JWKS_PRIVATE_KEY, 'cookware_jwks_1'),
30
30
-
]),
31
31
-
sessionStore: new SessionStore(),
32
32
-
stateStore: new StateStore(),
33
33
-
});
34
34
-
};
-80
apps/api/src/auth/index.ts
···
1
1
-
import { Hono } from "hono";
2
2
-
import { getDidFromHandleOrDid } from "@cookware/lexicons";
3
3
-
import { getClient } from "./client.js";
4
4
-
import { z } from "zod";
5
5
-
import { Session } from "hono-sessions";
6
6
-
import { RecipesSession, getSessionAgent } from "../util/api.js";
7
7
-
8
8
-
export const authApp = new Hono<{
9
9
-
Variables: {
10
10
-
session: Session<RecipesSession>,
11
11
-
session_key_rotation: boolean,
12
12
-
},
13
13
-
}>();
14
14
-
15
15
-
authApp.get('/client-metadata.json', async ctx => {
16
16
-
const client = await getClient(ctx);
17
17
-
return ctx.json(client.clientMetadata);
18
18
-
});
19
19
-
20
20
-
authApp.get('/jwks.json', async ctx => {
21
21
-
const client = await getClient(ctx);
22
22
-
return ctx.json(client.jwks);
23
23
-
});
24
24
-
25
25
-
const loginSchema = z.object({
26
26
-
actor: z.string(),
27
27
-
});
28
28
-
29
29
-
authApp.get('/me', async ctx => {
30
30
-
const agent = await getSessionAgent(ctx);
31
31
-
if (!agent) {
32
32
-
ctx.status(401);
33
33
-
return ctx.json({
34
34
-
error: 'unauthenticated',
35
35
-
message: 'You must be authenticated to access this resource.',
36
36
-
});
37
37
-
}
38
38
-
39
39
-
const profile = await agent.getProfile({ actor: agent.did! });
40
40
-
return ctx.json(profile.data);
41
41
-
});
42
42
-
43
43
-
authApp.post('/login', async ctx => {
44
44
-
const client = await getClient(ctx);
45
45
-
const { actor } = loginSchema.parse(await ctx.req.raw.json());
46
46
-
47
47
-
const did = await getDidFromHandleOrDid(actor);
48
48
-
if (!did) {
49
49
-
ctx.status(400);
50
50
-
return ctx.json({
51
51
-
error: 'DID_NOT_FOUND' as const,
52
52
-
message: 'No account with that handle was found.',
53
53
-
});
54
54
-
}
55
55
-
56
56
-
const url = await client.authorize(did, {
57
57
-
scope: 'atproto transition:generic',
58
58
-
});
59
59
-
return ctx.json({ url });
60
60
-
});
61
61
-
62
62
-
authApp.get('/callback', async ctx => {
63
63
-
const client = await getClient(ctx);
64
64
-
const params = new URLSearchParams(ctx.req.url.split('?')[1]);
65
65
-
66
66
-
const { session } = await client.callback(params);
67
67
-
const currentSession = ctx.get('session') as Session<RecipesSession>;
68
68
-
const did = currentSession.get('did');
69
69
-
if (did) {
70
70
-
ctx.status(400);
71
71
-
return ctx.json({
72
72
-
error: 'session_exists',
73
73
-
message: 'Session already exists!',
74
74
-
});
75
75
-
}
76
76
-
77
77
-
currentSession.set('did', session.did);
78
78
-
79
79
-
return ctx.redirect('/');
80
80
-
});
-55
apps/api/src/auth/storage.ts
···
1
1
-
import type {
2
2
-
NodeSavedSession,
3
3
-
NodeSavedSessionStore,
4
4
-
NodeSavedState,
5
5
-
NodeSavedStateStore,
6
6
-
} from '@atproto/oauth-client-node'
7
7
-
import { db, authSessionTable, authStateTable } from '@cookware/database'
8
8
-
import { eq } from 'drizzle-orm';
9
9
-
10
10
-
export class StateStore implements NodeSavedStateStore {
11
11
-
constructor() {}
12
12
-
async get(key: string): Promise<NodeSavedState | undefined> {
13
13
-
const result = await db.query.authStateTable.findFirst({
14
14
-
where: eq(authStateTable.key, key),
15
15
-
});
16
16
-
if (!result) return
17
17
-
return result.state
18
18
-
}
19
19
-
20
20
-
async set(key: string, state: NodeSavedState) {
21
21
-
await db.insert(authStateTable).values({
22
22
-
key, state: state,
23
23
-
}).onConflictDoUpdate({
24
24
-
target: authStateTable.key,
25
25
-
set: { state },
26
26
-
});
27
27
-
}
28
28
-
29
29
-
async del(key: string) {
30
30
-
await db.delete(authStateTable).where(eq(authStateTable.key, key));
31
31
-
}
32
32
-
}
33
33
-
34
34
-
export class SessionStore implements NodeSavedSessionStore {
35
35
-
async get(key: string): Promise<NodeSavedSession | undefined> {
36
36
-
const result = await db.query.authSessionTable.findFirst({
37
37
-
where: eq(authSessionTable.key, key),
38
38
-
});
39
39
-
if (!result) return
40
40
-
return result.session
41
41
-
}
42
42
-
43
43
-
async set(key: string, session: NodeSavedSession) {
44
44
-
await db.insert(authSessionTable)
45
45
-
.values({ key, session })
46
46
-
.onConflictDoUpdate({
47
47
-
target: authSessionTable.key,
48
48
-
set: { session },
49
49
-
});
50
50
-
}
51
51
-
52
52
-
async del(key: string) {
53
53
-
await db.delete(authSessionTable).where(eq(authSessionTable.key, key));
54
54
-
}
55
55
-
}
+1
-58
apps/api/src/index.ts
···
4
4
import env from "./config/env.js";
5
5
import { xrpcApp } from "./xrpc/index.js";
6
6
import { cors } from "hono/cors";
7
7
-
import { authApp } from "./auth/index.js";
8
7
import { ZodError } from "zod";
9
9
-
import { CookieStore, Session, sessionMiddleware } from "hono-sessions";
10
10
-
import { RecipesSession } from "./util/api.js";
11
8
import * as Sentry from "@sentry/node"
12
12
-
import { readFileSync } from "fs";
13
13
-
import { getFilePathWithoutDefaultDocument } from "hono/utils/filepath";
14
9
import { recipeApp } from "./recipes/index.js";
15
10
16
11
if (env.SENTRY_DSN) {
···
19
14
});
20
15
}
21
16
22
22
-
const app = new Hono<{
23
23
-
Variables: {
24
24
-
session: Session<RecipesSession>,
25
25
-
session_key_rotation: boolean,
26
26
-
},
27
27
-
}>();
28
28
-
29
29
-
const store = new CookieStore({
30
30
-
sessionCookieName: 'recipes-session',
31
31
-
});
32
32
-
33
33
-
app.use(async (c, next) => {
34
34
-
if (
35
35
-
c.req.path == '/oauth/client-metadata.json'
36
36
-
|| c.req.path == '/oauth/jwks.json'
37
37
-
) return next();
38
38
-
39
39
-
const mw = sessionMiddleware({
40
40
-
store,
41
41
-
encryptionKey: env.SESSION_KEY, // Required for CookieStore, recommended for others
42
42
-
expireAfterSeconds: 900, // Expire session after 15 minutes of inactivity
43
43
-
cookieOptions: {
44
44
-
sameSite: 'strict', // Recommended for basic CSRF protection in modern browsers
45
45
-
path: '/', // Required for this library to work properly
46
46
-
httpOnly: true, // Recommended to avoid XSS attacks
47
47
-
secure: true,
48
48
-
},
49
49
-
});
50
50
-
return mw(c, next);
51
51
-
});
17
17
+
const app = new Hono();
52
18
53
19
app.use(cors({
54
20
origin: (origin, _ctx) => {
···
69
35
}));
70
36
71
37
app.route('/xrpc', xrpcApp);
72
72
-
app.route('/oauth', authApp);
73
38
app.route('/api/recipes', recipeApp);
74
39
75
40
app.use(async (ctx, next) => {
···
90
55
message: 'The server could not process the request.',
91
56
});
92
57
}
93
93
-
});
94
94
-
95
95
-
// TODO: Replace custom impl with this when issue is addressed:
96
96
-
// https://github.com/honojs/hono/issues/3736
97
97
-
// app.use('/*', serveStatic({ root: env.PUBLIC_DIR, rewriteRequestPath: () => 'index.html' }));
98
98
-
99
99
-
app.use('/*', async (ctx, next) => {
100
100
-
if (ctx.finalized) return next();
101
101
-
102
102
-
let path = getFilePathWithoutDefaultDocument({
103
103
-
filename: 'index.html',
104
104
-
root: env.PUBLIC_DIR,
105
105
-
})
106
106
-
107
107
-
if (path) {
108
108
-
path = `./${path}`;
109
109
-
} else {
110
110
-
return next();
111
111
-
}
112
112
-
113
113
-
const index = readFileSync(path).toString();
114
114
-
return ctx.html(index);
115
58
});
116
59
117
60
serve({
+45
-27
apps/api/src/recipes/index.ts
···
1
1
import { Hono } from "hono";
2
2
-
import { getSessionAgent } from "../util/api.js";
3
3
-
import { RecipeCollection, RecipeRecord } from "@cookware/lexicons";
2
2
+
import { getDidDoc, getPdsUrl, RecipeCollection, RecipeRecord } from "@cookware/lexicons";
4
3
import { TID } from "@atproto/common";
4
4
+
import { verifyJwt } from "../util/jwt.js";
5
5
+
import { XRPCError } from "../util/xrpc.js";
5
6
6
7
export const recipeApp = new Hono();
7
8
8
9
recipeApp.post('/', async ctx => {
9
9
-
const agent = await getSessionAgent(ctx);
10
10
-
if (!agent) {
11
11
-
ctx.status(401);
12
12
-
return ctx.json({
13
13
-
error: 'unauthenticated',
14
14
-
message: 'You must be authenticated to access this resource.',
15
15
-
});
10
10
+
const authz = ctx.req.header('Authorization');
11
11
+
if (!authz || !authz.startsWith('Bearer ')) {
12
12
+
throw new XRPCError('this endpoint requires authentication', 'authz_required', 401);
16
13
}
17
14
18
18
-
const body = await ctx.req.json();
19
19
-
const { data: record, success, error } = RecipeRecord.safeParse(body);
20
20
-
if (!success) {
21
21
-
ctx.status(400);
22
22
-
return ctx.json({
23
23
-
error: 'invalid_recipe',
24
24
-
message: error.message,
25
25
-
fields: error.formErrors,
26
26
-
});
15
15
+
try {
16
16
+
const serviceJwt = await verifyJwt(
17
17
+
authz.split(' ')[1]!,
18
18
+
'did:web:recipes.blue#api',
19
19
+
null,
20
20
+
async (iss, forceRefresh) => {
21
21
+
console.log(iss);
22
22
+
return '';
23
23
+
},
24
24
+
);
27
25
}
28
26
29
29
-
const res = await agent.com.atproto.repo.putRecord({
30
30
-
repo: agent.assertDid,
31
31
-
collection: RecipeCollection,
32
32
-
record: record,
33
33
-
rkey: TID.nextStr(),
34
34
-
validate: false,
35
35
-
});
36
36
-
37
37
-
return ctx.json(res.data);
27
27
+
//const agent = await getSessionAgent(ctx);
28
28
+
//if (!agent) {
29
29
+
// ctx.status(401);
30
30
+
// return ctx.json({
31
31
+
// error: 'unauthenticated',
32
32
+
// message: 'You must be authenticated to access this resource.',
33
33
+
// });
34
34
+
//}
35
35
+
//
36
36
+
//const body = await ctx.req.json();
37
37
+
//const { data: record, success, error } = RecipeRecord.safeParse(body);
38
38
+
//if (!success) {
39
39
+
// ctx.status(400);
40
40
+
// return ctx.json({
41
41
+
// error: 'invalid_recipe',
42
42
+
// message: error.message,
43
43
+
// fields: error.formErrors,
44
44
+
// });
45
45
+
//}
46
46
+
//
47
47
+
//const res = await agent.com.atproto.repo.putRecord({
48
48
+
// repo: agent.assertDid,
49
49
+
// collection: RecipeCollection,
50
50
+
// record: record,
51
51
+
// rkey: TID.nextStr(),
52
52
+
// validate: false,
53
53
+
//});
54
54
+
//
55
55
+
//return ctx.json(res.data);
38
56
});
-23
apps/api/src/util/api.ts
···
1
1
-
import { Context } from "hono";
2
2
-
import { Session } from "hono-sessions";
3
3
-
import { getClient } from "../auth/client.js";
4
4
-
import { Agent } from "@atproto/api";
5
5
-
import { authLogger } from "../logger.js";
6
6
-
7
7
-
export type RecipesSession = { did: string; };
8
8
-
9
9
-
export const getSessionAgent = async (ctx: Context) => {
10
10
-
const client = await getClient(ctx);
11
11
-
const session = ctx.get('session') as Session<RecipesSession>;
12
12
-
const did = session.get('did');
13
13
-
if (!did) return null;
14
14
-
15
15
-
try {
16
16
-
const oauthSession = await client.restore(did);
17
17
-
return oauthSession ? new Agent(oauthSession) : null;
18
18
-
} catch (err) {
19
19
-
authLogger.warn({ err, did }, 'oauth restore failed');
20
20
-
session.deleteSession();
21
21
-
return null;
22
22
-
}
23
23
-
};
+229
apps/api/src/util/jwt.ts
···
1
1
+
import * as common from "@atproto/common";
2
2
+
import { MINUTE } from "@atproto/common";
3
3
+
import * as crypto from "@atproto/crypto";
4
4
+
import * as ui8 from "uint8arrays";
5
5
+
import { XRPCError } from "./xrpc.js";
6
6
+
import { z } from "zod";
7
7
+
8
8
+
export const jwt = z.custom<`${string}.${string}.${string}`>((input) => {
9
9
+
if (typeof input !== "string") return;
10
10
+
if (input.split(".").length !== 3) return false;
11
11
+
return true;
12
12
+
});
13
13
+
14
14
+
type ServiceJwtParams = {
15
15
+
iss: string;
16
16
+
aud: string;
17
17
+
iat?: number;
18
18
+
exp?: number;
19
19
+
lxm: string | null;
20
20
+
keypair: crypto.Keypair;
21
21
+
};
22
22
+
23
23
+
type ServiceJwtHeaders = {
24
24
+
alg: string;
25
25
+
} & Record<string, unknown>;
26
26
+
27
27
+
type ServiceJwtPayload = {
28
28
+
iss: string;
29
29
+
aud: string;
30
30
+
exp: number;
31
31
+
lxm?: string;
32
32
+
jti?: string;
33
33
+
};
34
34
+
35
35
+
export const createServiceJwt = async (
36
36
+
params: ServiceJwtParams,
37
37
+
): Promise<string> => {
38
38
+
const { iss, aud, keypair } = params;
39
39
+
const iat = params.iat ?? Math.floor(Date.now() / 1e3);
40
40
+
const exp = params.exp ?? iat + MINUTE / 1e3;
41
41
+
const lxm = params.lxm ?? undefined;
42
42
+
const jti = crypto.randomStr(16, "hex");
43
43
+
const header = {
44
44
+
typ: "JWT",
45
45
+
alg: keypair.jwtAlg,
46
46
+
};
47
47
+
const payload = common.noUndefinedVals({
48
48
+
iat,
49
49
+
iss,
50
50
+
aud,
51
51
+
exp,
52
52
+
lxm,
53
53
+
jti,
54
54
+
});
55
55
+
const toSignStr = `${jsonToB64Url(header)}.${jsonToB64Url(payload)}`;
56
56
+
const toSign = ui8.fromString(toSignStr, "utf8");
57
57
+
const sig = await keypair.sign(toSign);
58
58
+
return `${toSignStr}.${ui8.toString(sig, "base64url")}`;
59
59
+
};
60
60
+
61
61
+
export const createServiceAuthHeaders = async (params: ServiceJwtParams) => {
62
62
+
const jwt = await createServiceJwt(params);
63
63
+
return {
64
64
+
headers: { authorization: `Bearer ${jwt}` },
65
65
+
};
66
66
+
};
67
67
+
68
68
+
const jsonToB64Url = (json: Record<string, unknown>): string => {
69
69
+
return common.utf8ToB64Url(JSON.stringify(json));
70
70
+
};
71
71
+
72
72
+
export type VerifySignatureWithKeyFn = (
73
73
+
key: string,
74
74
+
msgBytes: Uint8Array,
75
75
+
sigBytes: Uint8Array,
76
76
+
alg: string,
77
77
+
) => Promise<boolean>;
78
78
+
79
79
+
export const verifyJwt = async (
80
80
+
jwtStr: string,
81
81
+
ownDid: string | null, // null indicates to skip the audience check
82
82
+
lxm: string | null, // null indicates to skip the lxm check
83
83
+
getSigningKey: (iss: string, forceRefresh: boolean) => Promise<string>,
84
84
+
verifySignatureWithKey: VerifySignatureWithKeyFn = cryptoVerifySignatureWithKey,
85
85
+
): Promise<ServiceJwtPayload> => {
86
86
+
const jwtParsed = jwt.safeParse(jwtStr);
87
87
+
if (!jwtParsed.success) {
88
88
+
throw new XRPCError("poorly formatted jwt", "BadJwt", 401);
89
89
+
}
90
90
+
const parts = jwtParsed.data.split(".");
91
91
+
92
92
+
const header = parseHeader(parts[0]!);
93
93
+
94
94
+
// The spec does not describe what to do with the "typ" claim. We can,
95
95
+
// however, forbid some values that are not compatible with our use case.
96
96
+
if (
97
97
+
// service tokens are not OAuth 2.0 access tokens
98
98
+
// https://datatracker.ietf.org/doc/html/rfc9068
99
99
+
header["typ"] === "at+jwt" ||
100
100
+
// "refresh+jwt" is a non-standard type used by the @atproto packages
101
101
+
header["typ"] === "refresh+jwt" ||
102
102
+
// "DPoP" proofs are not meant to be used as service tokens
103
103
+
// https://datatracker.ietf.org/doc/html/rfc9449
104
104
+
header["typ"] === "dpop+jwt"
105
105
+
) {
106
106
+
throw new XRPCError(
107
107
+
`Invalid jwt type "${header["typ"]}"`,
108
108
+
"BadJwtType",
109
109
+
401,
110
110
+
);
111
111
+
}
112
112
+
113
113
+
const payload = parsePayload(parts[1]!);
114
114
+
const sig = parts[2]!;
115
115
+
116
116
+
if (Date.now() / 1000 > payload.exp) {
117
117
+
throw new XRPCError("jwt expired", "JwtExpired", 401);
118
118
+
}
119
119
+
if (ownDid !== null && payload.aud !== ownDid) {
120
120
+
throw new XRPCError(
121
121
+
"jwt audience does not match service did",
122
122
+
"BadJwtAudience",
123
123
+
401,
124
124
+
);
125
125
+
}
126
126
+
if (lxm !== null && payload.lxm !== lxm) {
127
127
+
throw new XRPCError(
128
128
+
payload.lxm !== undefined
129
129
+
? `bad jwt lexicon method ("lxm"). must match: ${lxm}`
130
130
+
: `missing jwt lexicon method ("lxm"). must match: ${lxm}`,
131
131
+
"BadJwtLexiconMethod",
132
132
+
401,
133
133
+
);
134
134
+
}
135
135
+
136
136
+
const msgBytes = ui8.fromString(parts.slice(0, 2).join("."), "utf8");
137
137
+
const sigBytes = ui8.fromString(sig, "base64url");
138
138
+
139
139
+
const signingKey = await getSigningKey(payload.iss, false);
140
140
+
const { alg } = header;
141
141
+
142
142
+
let validSig: boolean;
143
143
+
try {
144
144
+
validSig = await verifySignatureWithKey(
145
145
+
signingKey,
146
146
+
msgBytes,
147
147
+
sigBytes,
148
148
+
alg,
149
149
+
);
150
150
+
} catch (err) {
151
151
+
throw new XRPCError(
152
152
+
"could not verify jwt signature",
153
153
+
"BadJwtSignature",
154
154
+
401,
155
155
+
);
156
156
+
}
157
157
+
158
158
+
if (!validSig) {
159
159
+
// get fresh signing key in case it failed due to a recent rotation
160
160
+
const freshSigningKey = await getSigningKey(payload.iss, true);
161
161
+
try {
162
162
+
validSig =
163
163
+
freshSigningKey !== signingKey
164
164
+
? await verifySignatureWithKey(
165
165
+
freshSigningKey,
166
166
+
msgBytes,
167
167
+
sigBytes,
168
168
+
alg,
169
169
+
)
170
170
+
: false;
171
171
+
} catch (err) {
172
172
+
throw new XRPCError(
173
173
+
"could not verify jwt signature",
174
174
+
"BadJwtSignature",
175
175
+
401,
176
176
+
);
177
177
+
}
178
178
+
}
179
179
+
180
180
+
if (!validSig) {
181
181
+
throw new XRPCError(
182
182
+
"jwt signature does not match jwt issuer",
183
183
+
"BadJwtSignature",
184
184
+
401,
185
185
+
);
186
186
+
}
187
187
+
188
188
+
return payload;
189
189
+
};
190
190
+
191
191
+
export const cryptoVerifySignatureWithKey: VerifySignatureWithKeyFn = async (
192
192
+
key: string,
193
193
+
msgBytes: Uint8Array,
194
194
+
sigBytes: Uint8Array,
195
195
+
alg: string,
196
196
+
) => {
197
197
+
return crypto.verifySignature(key, msgBytes, sigBytes, {
198
198
+
jwtAlg: alg,
199
199
+
allowMalleableSig: true,
200
200
+
});
201
201
+
};
202
202
+
203
203
+
const parseB64UrlToJson = (b64: string) => {
204
204
+
return JSON.parse(common.b64UrlToUtf8(b64));
205
205
+
};
206
206
+
207
207
+
const parseHeader = (b64: string): ServiceJwtHeaders => {
208
208
+
const header = parseB64UrlToJson(b64);
209
209
+
if (!header || typeof header !== "object" || typeof header.alg !== "string") {
210
210
+
throw new XRPCError("poorly formatted jwt", "BadJwt", 401);
211
211
+
}
212
212
+
return header;
213
213
+
};
214
214
+
215
215
+
const parsePayload = (b64: string): ServiceJwtPayload => {
216
216
+
const payload = parseB64UrlToJson(b64);
217
217
+
if (
218
218
+
!payload ||
219
219
+
typeof payload !== "object" ||
220
220
+
typeof payload.iss !== "string" ||
221
221
+
typeof payload.aud !== "string" ||
222
222
+
typeof payload.exp !== "number" ||
223
223
+
(payload.lxm && typeof payload.lxm !== "string") ||
224
224
+
(payload.nonce && typeof payload.nonce !== "string")
225
225
+
) {
226
226
+
throw new XRPCError("poorly formatted jwt", "BadJwt", 401);
227
227
+
}
228
228
+
return payload;
229
229
+
};
+20
apps/api/src/util/xrpc.ts
···
1
1
+
import { Context } from "hono";
2
2
+
import { StatusCode } from "hono/utils/http-status";
3
3
+
4
4
+
export class XRPCError extends Error {
5
5
+
constructor(
6
6
+
message: string,
7
7
+
public error: string,
8
8
+
public code: StatusCode,
9
9
+
) {
10
10
+
super(message);
11
11
+
}
12
12
+
13
13
+
hono(ctx: Context) {
14
14
+
ctx.status(this.code);
15
15
+
return ctx.json({
16
16
+
error: this.error,
17
17
+
message: this.message,
18
18
+
});
19
19
+
}
20
20
+
}
+51
-4
pnpm-lock.yaml
···
23
23
'@atproto/common':
24
24
specifier: ^0.4.5
25
25
version: 0.4.5
26
26
+
'@atproto/crypto':
27
27
+
specifier: ^0.4.2
28
28
+
version: 0.4.2
26
29
'@atproto/jwk-jose':
27
30
specifier: ^0.1.2
28
31
version: 0.1.2
···
49
52
version: 4.0.8
50
53
drizzle-orm:
51
54
specifier: ^0.37.0
52
52
-
version: 0.37.0(@libsql/client@0.14.0(bufferutil@4.0.8))(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.0.1)(react@19.0.0)
55
55
+
version: 0.37.0(@libsql/client@0.14.0)(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.0.1)(react@19.0.0)
53
56
hono:
54
57
specifier: ^4.6.12
55
58
version: 4.6.12
···
62
65
pino:
63
66
specifier: ^9.5.0
64
67
version: 9.5.0
68
68
+
uint8arrays:
69
69
+
specifier: ^5.1.0
70
70
+
version: 5.1.0
65
71
ws:
66
72
specifier: ^8.18.0
67
73
version: 8.18.0(bufferutil@4.0.8)
···
125
131
version: 4.0.8
126
132
drizzle-orm:
127
133
specifier: ^0.37.0
128
128
-
version: 0.37.0(@libsql/client@0.14.0(bufferutil@4.0.8))(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.0.1)(react@19.0.0)
134
134
+
version: 0.37.0(@libsql/client@0.14.0)(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.0.1)(react@19.0.0)
129
135
pino:
130
136
specifier: ^9.5.0
131
137
version: 9.5.0
···
328
334
version: 0.14.0(bufferutil@4.0.8)
329
335
drizzle-orm:
330
336
specifier: ^0.37.0
331
331
-
version: 0.37.0(@libsql/client@0.14.0(bufferutil@4.0.8))(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.0.1)(react@19.0.0)
337
337
+
version: 0.37.0(@libsql/client@0.14.0)(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.0.1)(react@19.0.0)
332
338
zod:
333
339
specifier: ^3.23.8
334
340
version: 3.23.8
···
432
438
433
439
'@atproto/common@0.4.5':
434
440
resolution: {integrity: sha512-LFAGqHcxCI5+b31Xgk+VQQtZU258iGPpHJzNeHVcdh6teIKZi4C2l6YV+m+3CEz+yYcfP7jjUmgqesx7l9Arsg==}
441
441
+
442
442
+
'@atproto/crypto@0.4.2':
443
443
+
resolution: {integrity: sha512-aeOfPQYCDbhn2hV06oBF2KXrWjf/BK4yL8lfANJKSmKl3tKWCkiW/moi643rUXXxSE72KtWtQeqvNFYnnFJ0ig==}
435
444
436
445
'@atproto/did@0.1.3':
437
446
resolution: {integrity: sha512-ULD8Gw/KRRwLFZ2Z2L4DjmdOMrg8IYYlcjdSc+GQ2/QJSVnD2zaJJVTLd3vls121wGt/583rNaiZTT2DpBze4w==}
···
1343
1352
1344
1353
'@neon-rs/load@0.0.4':
1345
1354
resolution: {integrity: sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw==}
1355
1355
+
1356
1356
+
'@noble/curves@1.7.0':
1357
1357
+
resolution: {integrity: sha512-UTMhXK9SeDhFJVrHeUJ5uZlI6ajXg10O6Ddocf9S6GjbSBVZsJo88HzKwXznNfGpMTRDyJkqMjNDPYgf0qFWnw==}
1358
1358
+
engines: {node: ^14.21.3 || >=16}
1359
1359
+
1360
1360
+
'@noble/hashes@1.6.0':
1361
1361
+
resolution: {integrity: sha512-YUULf0Uk4/mAA89w+k3+yUYh6NrEvxZa5T6SY3wlMvE2chHkxFUUIDI8/XW1QSC357iA5pSnqt7XEhvFOqmDyQ==}
1362
1362
+
engines: {node: ^14.21.3 || >=16}
1363
1363
+
1364
1364
+
'@noble/hashes@1.6.1':
1365
1365
+
resolution: {integrity: sha512-pq5D8h10hHBjyqX+cfBm0i8JUXJ0UhczFc4r74zbuT9XgewFo2E3J1cOaGtdZynILNmQ685YWGzGE1Zv6io50w==}
1366
1366
+
engines: {node: ^14.21.3 || >=16}
1346
1367
1347
1368
'@nodelib/fs.scandir@2.1.5':
1348
1369
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
···
3347
3368
ms@2.1.3:
3348
3369
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
3349
3370
3371
3371
+
multiformats@13.3.1:
3372
3372
+
resolution: {integrity: sha512-QxowxTNwJ3r5RMctoGA5p13w5RbRT2QDkoM+yFlqfLiioBp78nhDjnRLvmSBI9+KAqN4VdgOVWM9c0CHd86m3g==}
3373
3373
+
3350
3374
multiformats@9.9.0:
3351
3375
resolution: {integrity: sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==}
3352
3376
···
4158
4182
uint8arrays@3.0.0:
4159
4183
resolution: {integrity: sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA==}
4160
4184
4185
4185
+
uint8arrays@5.1.0:
4186
4186
+
resolution: {integrity: sha512-vA6nFepEmlSKkMBnLBaUMVvAC4G3CTmO58C12y4sq6WPDOR7mOFYOi7GlrQ4djeSbP6JG9Pv9tJDM97PedRSww==}
4187
4187
+
4161
4188
undici-types@6.20.0:
4162
4189
resolution: {integrity: sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==}
4163
4190
···
4422
4449
multiformats: 9.9.0
4423
4450
pino: 8.21.0
4424
4451
4452
4452
+
'@atproto/crypto@0.4.2':
4453
4453
+
dependencies:
4454
4454
+
'@noble/curves': 1.7.0
4455
4455
+
'@noble/hashes': 1.6.1
4456
4456
+
uint8arrays: 3.0.0
4457
4457
+
4425
4458
'@atproto/did@0.1.3':
4426
4459
dependencies:
4427
4460
zod: 3.23.8
···
5120
5153
5121
5154
'@neon-rs/load@0.0.4': {}
5122
5155
5156
5156
+
'@noble/curves@1.7.0':
5157
5157
+
dependencies:
5158
5158
+
'@noble/hashes': 1.6.0
5159
5159
+
5160
5160
+
'@noble/hashes@1.6.0': {}
5161
5161
+
5162
5162
+
'@noble/hashes@1.6.1': {}
5163
5163
+
5123
5164
'@nodelib/fs.scandir@2.1.5':
5124
5165
dependencies:
5125
5166
'@nodelib/fs.stat': 2.0.5
···
6624
6665
transitivePeerDependencies:
6625
6666
- supports-color
6626
6667
6627
6627
-
drizzle-orm@0.37.0(@libsql/client@0.14.0(bufferutil@4.0.8))(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.0.1)(react@19.0.0):
6668
6668
+
drizzle-orm@0.37.0(@libsql/client@0.14.0)(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.0.1)(react@19.0.0):
6628
6669
optionalDependencies:
6629
6670
'@libsql/client': 0.14.0(bufferutil@4.0.8)
6630
6671
'@opentelemetry/api': 1.9.0
···
7153
7194
module-details-from-path@1.0.3: {}
7154
7195
7155
7196
ms@2.1.3: {}
7197
7197
+
7198
7198
+
multiformats@13.3.1: {}
7156
7199
7157
7200
multiformats@9.9.0: {}
7158
7201
···
7981
8024
uint8arrays@3.0.0:
7982
8025
dependencies:
7983
8026
multiformats: 9.9.0
8027
8027
+
8028
8028
+
uint8arrays@5.1.0:
8029
8029
+
dependencies:
8030
8030
+
multiformats: 13.3.1
7984
8031
7985
8032
undici-types@6.20.0: {}
7986
8033