tangled
alpha
login
or
join now
stevedylan.dev
/
sequoia
35
fork
atom
A CLI for publishing standard.site documents to ATProto
sequoia.pub
standard
site
lexicon
cli
publishing
35
fork
atom
overview
issues
5
pulls
1
pipelines
chore: refactored to use atproto oauth lib
stevedylan.dev
1 month ago
a00efa48
19da1428
+340
-5
8 changed files
expand all
collapse all
unified
split
bun.lock
docs
package.json
src
index.ts
lib
kv-stores.ts
oauth-client.ts
session.ts
routes
auth.ts
wrangler.toml
+9
-2
bun.lock
reviewed
···
13
13
"name": "docs",
14
14
"version": "0.0.0",
15
15
"dependencies": {
16
16
+
"@atproto-labs/handle-resolver": "latest",
17
17
+
"@atproto/jwk-jose": "latest",
18
18
+
"@atproto/oauth-client": "latest",
16
19
"hono": "latest",
17
20
"react": "latest",
18
21
"react-dom": "latest",
···
92
95
93
96
"@atproto/lexicon": ["@atproto/lexicon@0.6.1", "", { "dependencies": { "@atproto/common-web": "^0.4.13", "@atproto/syntax": "^0.4.3", "iso-datestring-validator": "^2.2.2", "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-/vI1kVlY50Si+5MXpvOucelnYwb0UJ6Qto5mCp+7Q5C+Jtp+SoSykAPVvjVtTnQUH2vrKOFOwpb3C375vSKzXw=="],
94
97
95
95
-
"@atproto/oauth-client": ["@atproto/oauth-client@0.5.14", "", { "dependencies": { "@atproto-labs/did-resolver": "0.2.6", "@atproto-labs/fetch": "0.2.3", "@atproto-labs/handle-resolver": "0.3.6", "@atproto-labs/identity-resolver": "0.3.6", "@atproto-labs/simple-store": "0.3.0", "@atproto-labs/simple-store-memory": "0.1.4", "@atproto/did": "0.3.0", "@atproto/jwk": "0.6.0", "@atproto/oauth-types": "0.6.2", "@atproto/xrpc": "0.7.7", "core-js": "^3", "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-sPH+vcdq9maTEAhJI0HzmFcFAMrkCS19np+RUssNkX6kS8Xr3OYr57tvYRCbkcnIyYTfYcxKQgpwHKx3RVEaYw=="],
98
98
+
"@atproto/oauth-client": ["@atproto/oauth-client@0.6.0", "", { "dependencies": { "@atproto-labs/did-resolver": "^0.2.6", "@atproto-labs/fetch": "^0.2.3", "@atproto-labs/handle-resolver": "^0.3.6", "@atproto-labs/identity-resolver": "^0.3.6", "@atproto-labs/simple-store": "^0.3.0", "@atproto-labs/simple-store-memory": "^0.1.4", "@atproto/did": "^0.3.0", "@atproto/jwk": "^0.6.0", "@atproto/oauth-types": "^0.6.3", "@atproto/xrpc": "^0.7.7", "core-js": "^3", "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-F7ZTKzFptXgyihMkd7QTdRSkrh4XqrS+qTw+V81k5Q6Bh3MB1L3ypvfSJ6v7SSUJa6XxoZYJTCahHC1e+ndE6Q=="],
96
99
97
100
"@atproto/oauth-client-node": ["@atproto/oauth-client-node@0.3.16", "", { "dependencies": { "@atproto-labs/did-resolver": "0.2.6", "@atproto-labs/handle-resolver-node": "0.1.25", "@atproto-labs/simple-store": "0.3.0", "@atproto/did": "0.3.0", "@atproto/jwk": "0.6.0", "@atproto/jwk-jose": "0.1.11", "@atproto/jwk-webcrypto": "0.2.0", "@atproto/oauth-client": "0.5.14", "@atproto/oauth-types": "0.6.2" } }, "sha512-2dooMzxAkiQ4MkOAZlEQ3iwbB9SEovrbIKMNuBbVCLQYORVNxe20tMdjs3lvhrzdpzvaHLlQnJJhw5dA9VELFw=="],
98
101
99
99
-
"@atproto/oauth-types": ["@atproto/oauth-types@0.6.2", "", { "dependencies": { "@atproto/did": "0.3.0", "@atproto/jwk": "0.6.0", "zod": "^3.23.8" } }, "sha512-2cuboM4RQBCYR8NQC5uGRkW6KgCgKyq/B5/+tnMmWZYtZGVUQvsUWQHK/ZiMCnVXbcDNtc/RIEJQJDZ8FXMoxg=="],
102
102
+
"@atproto/oauth-types": ["@atproto/oauth-types@0.6.3", "", { "dependencies": { "@atproto/did": "^0.3.0", "@atproto/jwk": "^0.6.0", "zod": "^3.23.8" } }, "sha512-jdKuoPknJuh/WjI+mYk7agSbx9mNVMbS6Dr3k1z2YMY2oRiCQjxYBuo4MLKATbxj05nMQaZRWlHRUazoAu5Cng=="],
100
103
101
104
"@atproto/syntax": ["@atproto/syntax@0.4.3", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-YoZUz40YAJr5nPwvCDWgodEOlt5IftZqPJvA0JDWjuZKD8yXddTwSzXSaKQAzGOpuM+/A3uXRtPzJJqlScc+iA=="],
102
105
···
1535
1538
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
1536
1539
1537
1540
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
1541
1541
+
1542
1542
+
"@atproto/oauth-client-node/@atproto/oauth-client": ["@atproto/oauth-client@0.5.14", "", { "dependencies": { "@atproto-labs/did-resolver": "0.2.6", "@atproto-labs/fetch": "0.2.3", "@atproto-labs/handle-resolver": "0.3.6", "@atproto-labs/identity-resolver": "0.3.6", "@atproto-labs/simple-store": "0.3.0", "@atproto-labs/simple-store-memory": "0.1.4", "@atproto/did": "0.3.0", "@atproto/jwk": "0.6.0", "@atproto/oauth-types": "0.6.2", "@atproto/xrpc": "0.7.7", "core-js": "^3", "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-sPH+vcdq9maTEAhJI0HzmFcFAMrkCS19np+RUssNkX6kS8Xr3OYr57tvYRCbkcnIyYTfYcxKQgpwHKx3RVEaYw=="],
1543
1543
+
1544
1544
+
"@atproto/oauth-client-node/@atproto/oauth-types": ["@atproto/oauth-types@0.6.2", "", { "dependencies": { "@atproto/did": "0.3.0", "@atproto/jwk": "0.6.0", "zod": "^3.23.8" } }, "sha512-2cuboM4RQBCYR8NQC5uGRkW6KgCgKyq/B5/+tnMmWZYtZGVUQvsUWQHK/ZiMCnVXbcDNtc/RIEJQJDZ8FXMoxg=="],
1538
1545
1539
1546
"@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
1540
1547
+3
docs/package.json
reviewed
···
11
11
"preview": "vocs preview"
12
12
},
13
13
"dependencies": {
14
14
+
"@atproto/oauth-client": "latest",
15
15
+
"@atproto/jwk-jose": "latest",
16
16
+
"@atproto-labs/handle-resolver": "latest",
14
17
"hono": "latest",
15
18
"react": "latest",
16
19
"react-dom": "latest",
+4
-3
docs/src/index.ts
reviewed
···
1
1
import { Hono } from "hono";
2
2
+
import auth from "./routes/auth";
2
3
3
4
type Bindings = {
4
5
ASSETS: Fetcher;
6
6
+
SEQUOIA_SESSIONS: KVNamespace;
7
7
+
CLIENT_URL: string;
5
8
};
6
9
7
10
const app = new Hono<{ Bindings: Bindings }>();
8
11
9
9
-
app.get("/oauth/callback", (c) => {
10
10
-
return c.text("Not Implemented", 501);
11
11
-
});
12
12
+
app.route("/oauth", auth);
12
13
13
14
app.get("/api/health", (c) => {
14
15
return c.json({ status: "ok" });
+82
docs/src/lib/kv-stores.ts
reviewed
···
1
1
+
import { JoseKey } from "@atproto/jwk-jose";
2
2
+
import type {
3
3
+
Key,
4
4
+
InternalStateData,
5
5
+
SessionStore,
6
6
+
StateStore,
7
7
+
} from "@atproto/oauth-client";
8
8
+
9
9
+
type SerializedStateData = Omit<InternalStateData, "dpopKey"> & {
10
10
+
dpopJwk: Record<string, unknown>;
11
11
+
};
12
12
+
13
13
+
type SerializedSession = Omit<
14
14
+
Parameters<SessionStore["set"]>[1],
15
15
+
"dpopKey"
16
16
+
> & {
17
17
+
dpopJwk: Record<string, unknown>;
18
18
+
};
19
19
+
20
20
+
function serializeKey(key: Key): Record<string, unknown> {
21
21
+
const jwk = key.privateJwk;
22
22
+
if (!jwk) throw new Error("Private DPoP JWK is missing");
23
23
+
return jwk as Record<string, unknown>;
24
24
+
}
25
25
+
26
26
+
async function deserializeKey(jwk: Record<string, unknown>): Promise<Key> {
27
27
+
return JoseKey.fromJWK(jwk);
28
28
+
}
29
29
+
30
30
+
export function createStateStore(
31
31
+
kv: KVNamespace,
32
32
+
ttl = 600,
33
33
+
): StateStore {
34
34
+
return {
35
35
+
async set(key, { dpopKey, ...rest }) {
36
36
+
const data: SerializedStateData = {
37
37
+
...rest,
38
38
+
dpopJwk: serializeKey(dpopKey),
39
39
+
};
40
40
+
await kv.put(`oauth_state:${key}`, JSON.stringify(data), {
41
41
+
expirationTtl: ttl,
42
42
+
});
43
43
+
},
44
44
+
async get(key) {
45
45
+
const raw = await kv.get(`oauth_state:${key}`);
46
46
+
if (!raw) return undefined;
47
47
+
const { dpopJwk, ...rest }: SerializedStateData = JSON.parse(raw);
48
48
+
const dpopKey = await deserializeKey(dpopJwk);
49
49
+
return { ...rest, dpopKey };
50
50
+
},
51
51
+
async del(key) {
52
52
+
await kv.delete(`oauth_state:${key}`);
53
53
+
},
54
54
+
};
55
55
+
}
56
56
+
57
57
+
export function createSessionStore(
58
58
+
kv: KVNamespace,
59
59
+
ttl = 60 * 60 * 24 * 14,
60
60
+
): SessionStore {
61
61
+
return {
62
62
+
async set(sub, { dpopKey, ...rest }) {
63
63
+
const data: SerializedSession = {
64
64
+
...rest,
65
65
+
dpopJwk: serializeKey(dpopKey),
66
66
+
};
67
67
+
await kv.put(`oauth_session:${sub}`, JSON.stringify(data), {
68
68
+
expirationTtl: ttl,
69
69
+
});
70
70
+
},
71
71
+
async get(sub) {
72
72
+
const raw = await kv.get(`oauth_session:${sub}`);
73
73
+
if (!raw) return undefined;
74
74
+
const { dpopJwk, ...rest }: SerializedSession = JSON.parse(raw);
75
75
+
const dpopKey = await deserializeKey(dpopJwk);
76
76
+
return { ...rest, dpopKey };
77
77
+
},
78
78
+
async del(sub) {
79
79
+
await kv.delete(`oauth_session:${sub}`);
80
80
+
},
81
81
+
};
82
82
+
}
+43
docs/src/lib/oauth-client.ts
reviewed
···
1
1
+
import { JoseKey } from "@atproto/jwk-jose";
2
2
+
import { OAuthClient } from "@atproto/oauth-client";
3
3
+
import { AtprotoDohHandleResolver } from "@atproto-labs/handle-resolver";
4
4
+
import { createStateStore, createSessionStore } from "./kv-stores";
5
5
+
6
6
+
export function createOAuthClient(kv: KVNamespace, clientUrl: string) {
7
7
+
const clientId = `${clientUrl}/oauth/client-metadata.json`;
8
8
+
const redirectUri = `${clientUrl}/oauth/callback`;
9
9
+
10
10
+
return new OAuthClient({
11
11
+
responseMode: "query",
12
12
+
handleResolver: new AtprotoDohHandleResolver({
13
13
+
dohEndpoint: "https://cloudflare-dns.com/dns-query",
14
14
+
}),
15
15
+
clientMetadata: {
16
16
+
client_id: clientId,
17
17
+
client_name: "Sequoia",
18
18
+
client_uri: clientUrl,
19
19
+
redirect_uris: [redirectUri],
20
20
+
grant_types: ["authorization_code", "refresh_token"],
21
21
+
response_types: ["code"],
22
22
+
scope: "atproto transition:generic",
23
23
+
token_endpoint_auth_method: "none",
24
24
+
application_type: "web",
25
25
+
dpop_bound_access_tokens: true,
26
26
+
},
27
27
+
runtimeImplementation: {
28
28
+
createKey: (algs: string[]) => JoseKey.generate(algs),
29
29
+
getRandomValues: (length: number) =>
30
30
+
crypto.getRandomValues(new Uint8Array(length)),
31
31
+
digest: async (data: Uint8Array, { name }: { name: string }) => {
32
32
+
const buf = await crypto.subtle.digest(
33
33
+
name.replace("sha", "SHA-"),
34
34
+
new Uint8Array(data),
35
35
+
);
36
36
+
return new Uint8Array(buf);
37
37
+
},
38
38
+
requestLock: <T>(_name: string, fn: () => T | PromiseLike<T>) => fn(),
39
39
+
},
40
40
+
stateStore: createStateStore(kv),
41
41
+
sessionStore: createSessionStore(kv),
42
42
+
});
43
43
+
}
+47
docs/src/lib/session.ts
reviewed
···
1
1
+
import type { Context } from "hono";
2
2
+
3
3
+
const SESSION_COOKIE_NAME = "session_id";
4
4
+
const SESSION_TTL = 60 * 60 * 24 * 14; // 14 days in seconds
5
5
+
6
6
+
/**
7
7
+
* Get DID from session cookie
8
8
+
*/
9
9
+
export function getSessionDid(c: Context): string | null {
10
10
+
const cookie = c.req.header("Cookie");
11
11
+
if (!cookie) return null;
12
12
+
13
13
+
const match = cookie.match(new RegExp(`${SESSION_COOKIE_NAME}=([^;]+)`));
14
14
+
return match ? decodeURIComponent(match[1]) : null;
15
15
+
}
16
16
+
17
17
+
/**
18
18
+
* Set session cookie with the user's DID
19
19
+
*/
20
20
+
export function setSessionCookie(
21
21
+
c: Context,
22
22
+
did: string,
23
23
+
clientUrl: string,
24
24
+
): void {
25
25
+
const isLocalhost = clientUrl.includes("localhost");
26
26
+
const domain = isLocalhost ? "" : "; Domain=.sequoia.pub";
27
27
+
const secure = isLocalhost ? "" : "; Secure";
28
28
+
29
29
+
c.header(
30
30
+
"Set-Cookie",
31
31
+
`${SESSION_COOKIE_NAME}=${encodeURIComponent(did)}; HttpOnly; SameSite=Lax; Path=/${domain}${secure}; Max-Age=${SESSION_TTL}`,
32
32
+
);
33
33
+
}
34
34
+
35
35
+
/**
36
36
+
* Clear session cookie
37
37
+
*/
38
38
+
export function clearSessionCookie(c: Context, clientUrl: string): void {
39
39
+
const isLocalhost = clientUrl.includes("localhost");
40
40
+
const domain = isLocalhost ? "" : "; Domain=.sequoia.pub";
41
41
+
const secure = isLocalhost ? "" : "; Secure";
42
42
+
43
43
+
c.header(
44
44
+
"Set-Cookie",
45
45
+
`${SESSION_COOKIE_NAME}=; HttpOnly; SameSite=Lax; Path=/${domain}${secure}; Max-Age=0`,
46
46
+
);
47
47
+
}
+144
docs/src/routes/auth.ts
reviewed
···
1
1
+
import { Hono } from "hono";
2
2
+
import { createOAuthClient } from "../lib/oauth-client";
3
3
+
import {
4
4
+
getSessionDid,
5
5
+
setSessionCookie,
6
6
+
clearSessionCookie,
7
7
+
} from "../lib/session";
8
8
+
9
9
+
interface Env {
10
10
+
SEQUOIA_SESSIONS: KVNamespace;
11
11
+
CLIENT_URL: string;
12
12
+
}
13
13
+
14
14
+
const auth = new Hono<{ Bindings: Env }>();
15
15
+
16
16
+
// OAuth client metadata endpoint
17
17
+
auth.get("/client-metadata.json", (c) => {
18
18
+
const clientId = `${c.env.CLIENT_URL}/oauth/client-metadata.json`;
19
19
+
const redirectUri = `${c.env.CLIENT_URL}/oauth/callback`;
20
20
+
21
21
+
return c.json({
22
22
+
client_id: clientId,
23
23
+
client_name: "Sequoia",
24
24
+
client_uri: c.env.CLIENT_URL,
25
25
+
redirect_uris: [redirectUri],
26
26
+
grant_types: ["authorization_code", "refresh_token"],
27
27
+
response_types: ["code"],
28
28
+
scope: "atproto transition:generic",
29
29
+
token_endpoint_auth_method: "none",
30
30
+
application_type: "web",
31
31
+
dpop_bound_access_tokens: true,
32
32
+
});
33
33
+
});
34
34
+
35
35
+
// Start OAuth login flow
36
36
+
auth.get("/login", async (c) => {
37
37
+
try {
38
38
+
const handle = c.req.query("handle");
39
39
+
if (!handle) {
40
40
+
return c.redirect(`${c.env.CLIENT_URL}/?error=missing_handle`);
41
41
+
}
42
42
+
43
43
+
const client = createOAuthClient(c.env.SEQUOIA_SESSIONS, c.env.CLIENT_URL);
44
44
+
const authUrl = await client.authorize(handle, {
45
45
+
scope: "atproto transition:generic",
46
46
+
});
47
47
+
48
48
+
return c.redirect(authUrl.toString());
49
49
+
} catch (error) {
50
50
+
console.error("Login error:", error);
51
51
+
return c.redirect(`${c.env.CLIENT_URL}/?error=login_failed`);
52
52
+
}
53
53
+
});
54
54
+
55
55
+
// OAuth callback handler
56
56
+
auth.get("/callback", async (c) => {
57
57
+
try {
58
58
+
const params = new URLSearchParams(c.req.url.split("?")[1] || "");
59
59
+
60
60
+
if (params.get("error")) {
61
61
+
const error = params.get("error");
62
62
+
console.error("OAuth error:", error, params.get("error_description"));
63
63
+
return c.redirect(
64
64
+
`${c.env.CLIENT_URL}/?error=${encodeURIComponent(error!)}`,
65
65
+
);
66
66
+
}
67
67
+
68
68
+
const client = createOAuthClient(c.env.SEQUOIA_SESSIONS, c.env.CLIENT_URL);
69
69
+
const { session } = await client.callback(params);
70
70
+
71
71
+
// Resolve handle from DID
72
72
+
let handle: string | undefined;
73
73
+
try {
74
74
+
const identity = await client.identityResolver.resolve(session.did);
75
75
+
handle = identity.handle;
76
76
+
} catch {
77
77
+
// Handle resolution is best-effort
78
78
+
}
79
79
+
80
80
+
// Store handle in KV alongside the session for quick lookup
81
81
+
if (handle) {
82
82
+
await c.env.SEQUOIA_SESSIONS.put(`oauth_handle:${session.did}`, handle, {
83
83
+
expirationTtl: 60 * 60 * 24 * 14,
84
84
+
});
85
85
+
}
86
86
+
87
87
+
setSessionCookie(c, session.did, c.env.CLIENT_URL);
88
88
+
return c.redirect(`${c.env.CLIENT_URL}/`);
89
89
+
} catch (error) {
90
90
+
console.error("Callback error:", error);
91
91
+
return c.redirect(`${c.env.CLIENT_URL}/?error=callback_failed`);
92
92
+
}
93
93
+
});
94
94
+
95
95
+
// Logout endpoint
96
96
+
auth.post("/logout", async (c) => {
97
97
+
const did = getSessionDid(c);
98
98
+
99
99
+
if (did) {
100
100
+
try {
101
101
+
const client = createOAuthClient(
102
102
+
c.env.SEQUOIA_SESSIONS,
103
103
+
c.env.CLIENT_URL,
104
104
+
);
105
105
+
await client.revoke(did);
106
106
+
} catch (error) {
107
107
+
console.error("Revoke error:", error);
108
108
+
}
109
109
+
await c.env.SEQUOIA_SESSIONS.delete(`oauth_handle:${did}`);
110
110
+
}
111
111
+
112
112
+
clearSessionCookie(c, c.env.CLIENT_URL);
113
113
+
return c.json({ success: true });
114
114
+
});
115
115
+
116
116
+
// Check auth status
117
117
+
auth.get("/status", async (c) => {
118
118
+
const did = getSessionDid(c);
119
119
+
120
120
+
if (!did) {
121
121
+
return c.json({ authenticated: false });
122
122
+
}
123
123
+
124
124
+
try {
125
125
+
const client = createOAuthClient(c.env.SEQUOIA_SESSIONS, c.env.CLIENT_URL);
126
126
+
const session = await client.restore(did);
127
127
+
128
128
+
const handle = await c.env.SEQUOIA_SESSIONS.get(
129
129
+
`oauth_handle:${session.did}`,
130
130
+
);
131
131
+
132
132
+
return c.json({
133
133
+
authenticated: true,
134
134
+
did: session.did,
135
135
+
handle: handle || undefined,
136
136
+
});
137
137
+
} catch (error) {
138
138
+
console.error("Session restore failed:", error);
139
139
+
clearSessionCookie(c, c.env.CLIENT_URL);
140
140
+
return c.json({ authenticated: false });
141
141
+
}
142
142
+
});
143
143
+
144
144
+
export default auth;
+8
docs/wrangler.toml
reviewed
···
1
1
name = "sequoia-docs"
2
2
main = "src/index.ts"
3
3
compatibility_date = "2025-04-01"
4
4
+
compatibility_flags = ["nodejs_compat"]
4
5
5
6
[assets]
6
7
directory = "./docs/dist"
···
8
9
not_found_handling = "single-page-application"
9
10
html_handling = "auto-trailing-slash"
10
11
run_worker_first = ["/api/*", "/oauth/*"]
12
12
+
13
13
+
[[kv_namespaces]]
14
14
+
binding = "SEQUOIA_SESSIONS"
15
15
+
id = "b9fedf2798a249669b3aeeaca70a0bf8"
16
16
+
17
17
+
[vars]
18
18
+
CLIENT_URL = "https://sequoia.pub"