feat: added OAuth and posting to ATProto 3533bcfa
Steve · 2026-01-07 23:42 17 file(s) · +1429 −73
bun.lock +4 −0
44 44
    "packages/server": {
45 45
      "name": "server",
46 46
      "dependencies": {
47 +
        "@atproto/api": "^0.18.12",
47 48
        "feed": "^5.1.0",
48 49
        "hono": "^4.11.3",
50 +
        "jose": "^6.1.3",
49 51
      },
50 52
      "devDependencies": {
51 53
        "@cloudflare/workers-types": "^4.20260103.0",
759 761
    "iso-datestring-validator": ["iso-datestring-validator@2.2.2", "", {}, "sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA=="],
760 762
761 763
    "jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="],
764 +
765 +
    "jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="],
762 766
763 767
    "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
764 768
packages/client/astro.config.mjs +27 −0
3 3
import tailwind from "@astrojs/tailwind";
4 4
import sitemap from "@astrojs/sitemap";
5 5
import react from "@astrojs/react";
6 +
import { fileURLToPath } from "url";
7 +
import path from "path";
6 8
7 9
// https://astro.build/config
8 10
export default defineConfig({
27 29
		react(),
28 30
	],
29 31
	vite: {
32 +
		resolve: {
33 +
			alias: {
34 +
				"@/components": path.resolve(
35 +
					path.dirname(fileURLToPath(import.meta.url)),
36 +
					"./src/components",
37 +
				),
38 +
				"@/layouts": path.resolve(
39 +
					path.dirname(fileURLToPath(import.meta.url)),
40 +
					"./src/layouts",
41 +
				),
42 +
				"@/utils": path.resolve(
43 +
					path.dirname(fileURLToPath(import.meta.url)),
44 +
					"./src/utils/index.ts",
45 +
				),
46 +
				"@/stores": path.resolve(
47 +
					path.dirname(fileURLToPath(import.meta.url)),
48 +
					"./src/stores",
49 +
				),
50 +
				"@/data": path.resolve(
51 +
					path.dirname(fileURLToPath(import.meta.url)),
52 +
					"./src/data",
53 +
				),
54 +
			},
55 +
			extensions: [".mjs", ".js", ".ts", ".jsx", ".tsx", ".json", ".astro"],
56 +
		},
30 57
		ssr: {
31 58
			external: ["node:async_hooks"],
32 59
		},
packages/client/src/components/auth/AuthStatus.tsx (added) +113 −0
1 +
import { useState, useEffect, createContext, useContext, type ReactNode } from "react";
2 +
3 +
interface AuthState {
4 +
	authenticated: boolean;
5 +
	did?: string;
6 +
	handle?: string;
7 +
	loading: boolean;
8 +
}
9 +
10 +
interface AuthContextType extends AuthState {
11 +
	login: () => void;
12 +
	logout: () => Promise<void>;
13 +
	refresh: () => Promise<void>;
14 +
}
15 +
16 +
const AuthContext = createContext<AuthContextType | null>(null);
17 +
18 +
const API_URL = import.meta.env.PUBLIC_API_URL || "https://api.stevedylan.dev";
19 +
20 +
export function AuthProvider({ children }: { children: ReactNode }) {
21 +
	const [authState, setAuthState] = useState<AuthState>({
22 +
		authenticated: false,
23 +
		loading: true,
24 +
	});
25 +
26 +
	const checkAuthStatus = async () => {
27 +
		try {
28 +
			const response = await fetch(`${API_URL}/auth/status`, {
29 +
				credentials: "include",
30 +
			});
31 +
			const data = await response.json();
32 +
			setAuthState({
33 +
				authenticated: data.authenticated,
34 +
				did: data.did,
35 +
				handle: data.handle,
36 +
				loading: false,
37 +
			});
38 +
		} catch (error) {
39 +
			console.error("Failed to check auth status:", error);
40 +
			setAuthState({ authenticated: false, loading: false });
41 +
		}
42 +
	};
43 +
44 +
	useEffect(() => {
45 +
		checkAuthStatus();
46 +
	}, []);
47 +
48 +
	const login = () => {
49 +
		window.location.href = `${API_URL}/auth/login`;
50 +
	};
51 +
52 +
	const logout = async () => {
53 +
		try {
54 +
			await fetch(`${API_URL}/auth/logout`, {
55 +
				method: "POST",
56 +
				credentials: "include",
57 +
			});
58 +
			setAuthState({ authenticated: false, loading: false });
59 +
		} catch (error) {
60 +
			console.error("Logout failed:", error);
61 +
		}
62 +
	};
63 +
64 +
	const refresh = async () => {
65 +
		await checkAuthStatus();
66 +
	};
67 +
68 +
	return (
69 +
		<AuthContext.Provider value={{ ...authState, login, logout, refresh }}>
70 +
			{children}
71 +
		</AuthContext.Provider>
72 +
	);
73 +
}
74 +
75 +
export function useAuth() {
76 +
	const context = useContext(AuthContext);
77 +
	if (!context) {
78 +
		throw new Error("useAuth must be used within an AuthProvider");
79 +
	}
80 +
	return context;
81 +
}
82 +
83 +
export function LoginButton() {
84 +
	const { authenticated, loading, login, logout } = useAuth();
85 +
86 +
	if (loading) {
87 +
		return (
88 +
			<div className="text-sm text-gray-500">
89 +
				Checking auth...
90 +
			</div>
91 +
		);
92 +
	}
93 +
94 +
	if (authenticated) {
95 +
		return (
96 +
			<button
97 +
				onClick={logout}
98 +
				className="text-sm px-3 py-1.5 border border-gray-300 dark:border-gray-600 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
99 +
			>
100 +
				Logout
101 +
			</button>
102 +
		);
103 +
	}
104 +
105 +
	return (
106 +
		<button
107 +
			onClick={login}
108 +
			className="text-sm px-3 py-1.5 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
109 +
		>
110 +
			Login with ATProto
111 +
		</button>
112 +
	);
113 +
}
packages/client/src/components/post/NowAdmin.tsx (added) +28 −0
1 +
import { AuthProvider, LoginButton } from "../auth/AuthStatus";
2 +
import { PostComposer } from "./PostComposer";
3 +
4 +
declare global {
5 +
	interface Window {
6 +
		fetchPosts?: () => Promise<void>;
7 +
	}
8 +
}
9 +
10 +
export function NowAdmin() {
11 +
	const handlePostCreated = () => {
12 +
		// Call the global fetchPosts function to refresh the posts list
13 +
		if (typeof window !== "undefined" && window.fetchPosts) {
14 +
			window.fetchPosts();
15 +
		}
16 +
	};
17 +
18 +
	return (
19 +
		<AuthProvider>
20 +
			<div className="mb-6">
21 +
				<div className="flex justify-end mb-4">
22 +
					<LoginButton />
23 +
				</div>
24 +
				<PostComposer onPostCreated={handlePostCreated} />
25 +
			</div>
26 +
		</AuthProvider>
27 +
	);
28 +
}
packages/client/src/components/post/PostComposer.tsx (added) +114 −0
1 +
import { useState } from "react";
2 +
import { useAuth } from "../auth/AuthStatus";
3 +
4 +
const API_URL = import.meta.env.PUBLIC_API_URL || "https://api.stevedylan.dev";
5 +
const MAX_LENGTH = 300;
6 +
7 +
interface PostComposerProps {
8 +
	onPostCreated?: () => void;
9 +
}
10 +
11 +
export function PostComposer({ onPostCreated }: PostComposerProps) {
12 +
	const { authenticated } = useAuth();
13 +
	const [text, setText] = useState("");
14 +
	const [isSubmitting, setIsSubmitting] = useState(false);
15 +
	const [error, setError] = useState<string | null>(null);
16 +
	const [success, setSuccess] = useState(false);
17 +
18 +
	if (!authenticated) {
19 +
		return null;
20 +
	}
21 +
22 +
	const handleSubmit = async (e: React.FormEvent) => {
23 +
		e.preventDefault();
24 +
25 +
		if (!text.trim()) {
26 +
			setError("Post cannot be empty");
27 +
			return;
28 +
		}
29 +
30 +
		if (text.length > MAX_LENGTH) {
31 +
			setError(`Post must be ${MAX_LENGTH} characters or less`);
32 +
			return;
33 +
		}
34 +
35 +
		setIsSubmitting(true);
36 +
		setError(null);
37 +
		setSuccess(false);
38 +
39 +
		try {
40 +
			const response = await fetch(`${API_URL}/now/post`, {
41 +
				method: "POST",
42 +
				credentials: "include",
43 +
				headers: {
44 +
					"Content-Type": "application/json",
45 +
				},
46 +
				body: JSON.stringify({ text: text.trim() }),
47 +
			});
48 +
49 +
			const data = await response.json();
50 +
51 +
			if (!response.ok) {
52 +
				throw new Error(data.error || "Failed to create post");
53 +
			}
54 +
55 +
			setText("");
56 +
			setSuccess(true);
57 +
			setTimeout(() => setSuccess(false), 3000);
58 +
59 +
			// Notify parent to refresh posts
60 +
			onPostCreated?.();
61 +
		} catch (err) {
62 +
			setError(err instanceof Error ? err.message : "Failed to create post");
63 +
		} finally {
64 +
			setIsSubmitting(false);
65 +
		}
66 +
	};
67 +
68 +
	const remaining = MAX_LENGTH - text.length;
69 +
	const isOverLimit = remaining < 0;
70 +
71 +
	return (
72 +
		<form
73 +
			onSubmit={handleSubmit}
74 +
			className="mb-8 p-4 border border-gray-200 dark:border-gray-700 rounded-lg"
75 +
		>
76 +
			<label htmlFor="post-text" className="block text-sm font-medium mb-2">
77 +
				New Update
78 +
			</label>
79 +
			<textarea
80 +
				id="post-text"
81 +
				value={text}
82 +
				onChange={(e) => setText(e.target.value)}
83 +
				placeholder="What's happening?"
84 +
				rows={3}
85 +
				className="w-full p-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-gray-800 resize-none focus:outline-none text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
86 +
				disabled={isSubmitting}
87 +
			/>
88 +
89 +
			<div className="flex items-center justify-between mt-3">
90 +
				<div className="flex items-center gap-3">
91 +
					<span
92 +
						className={`text-sm ${isOverLimit ? "text-red-500" : remaining <= 20 ? "text-yellow-500" : "text-gray-500"}`}
93 +
					>
94 +
						{remaining}
95 +
					</span>
96 +
97 +
					{error && <span className="text-sm text-red-500">{error}</span>}
98 +
99 +
					{success && (
100 +
						<span className="text-sm text-green-500">Posted successfully!</span>
101 +
					)}
102 +
				</div>
103 +
104 +
				<button
105 +
					type="submit"
106 +
					disabled={isSubmitting || isOverLimit || !text.trim()}
107 +
					className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
108 +
				>
109 +
					{isSubmitting ? "Posting..." : "Post"}
110 +
				</button>
111 +
			</div>
112 +
		</form>
113 +
	);
114 +
}
packages/client/src/pages/now/index.astro +1 −0
1 1
---
2 2
import PageLayout from "@/layouts/Base";
3 +
3 4
const meta = {
4 5
	title: "Now",
5 6
	description: "What I'm up to recently",
packages/client/src/pages/now/post.astro (added) +16 −0
1 +
---
2 +
import PageLayout from "@/layouts/Base";
3 +
import { NowAdmin } from "@/components/post/NowAdmin";
4 +
5 +
const meta = {
6 +
	title: "Post Update",
7 +
	description: "Create a new update",
8 +
};
9 +
---
10 +
<PageLayout meta={meta}>
11 +
  <div class="space-y-6">
12 +
    <h1 class="title">Post Update</h1>
13 +
    <p>Create a new update for the <a href="/now" class="text-link">Now</a> page.</p>
14 +
    <NowAdmin client:load />
15 +
  </div>
16 +
</PageLayout>
packages/client/tsconfig.json +21 −38
1 1
{
2 -
  "extends": "astro/tsconfigs/base",
3 -
  "compilerOptions": {
4 -
    "strict": true,
5 -
    "baseUrl": ".",
6 -
    "paths": {
7 -
      "@/components/*": [
8 -
        "src/components/*.astro"
9 -
      ],
10 -
      "@/layouts/*": [
11 -
        "src/layouts/*.astro"
12 -
      ],
13 -
      "@/utils": [
14 -
        "src/utils/index.ts"
15 -
      ],
16 -
      "@/stores/*": [
17 -
        "src/stores/*"
18 -
      ],
19 -
      "@/data/*": [
20 -
        "src/data/*"
21 -
      ],
22 -
      "@/site-config": [
23 -
        "src/site.config.ts"
24 -
      ]
25 -
    },
26 -
    "plugins": [
27 -
      {
28 -
        "name": "@astrojs/ts-plugin"
29 -
      }
30 -
    ],
31 -
    "jsx": "react-jsx",
32 -
    "jsxImportSource": "react"
33 -
  },
34 -
  "exclude": [
35 -
    "node_modules",
36 -
    "**/node_modules/*",
37 -
    ".vscode",
38 -
    "dist"
39 -
  ]
2 +
	"extends": "astro/tsconfigs/base",
3 +
	"compilerOptions": {
4 +
		"strict": true,
5 +
		"baseUrl": ".",
6 +
		"paths": {
7 +
			"@/components/*": ["src/components/*"],
8 +
			"@/layouts/*": ["src/layouts/*"],
9 +
			"@/utils": ["src/utils/index.ts"],
10 +
			"@/stores/*": ["src/stores/*"],
11 +
			"@/data/*": ["src/data/*"],
12 +
			"@/site-config": ["src/site.config.ts"]
13 +
		},
14 +
		"plugins": [
15 +
			{
16 +
				"name": "@astrojs/ts-plugin"
17 +
			}
18 +
		],
19 +
		"jsx": "react-jsx",
20 +
		"jsxImportSource": "react"
21 +
	},
22 +
	"exclude": ["node_modules", "**/node_modules/*", ".vscode", "dist"]
40 23
}
packages/server/package.json +3 −1
9 9
		"typecheck": "tsc --noEmit"
10 10
	},
11 11
	"dependencies": {
12 +
		"@atproto/api": "^0.18.12",
12 13
		"feed": "^5.1.0",
13 -
		"hono": "^4.11.3"
14 +
		"hono": "^4.11.3",
15 +
		"jose": "^6.1.3"
14 16
	},
15 17
	"devDependencies": {
16 18
		"@cloudflare/workers-types": "^4.20260103.0",
packages/server/src/index.ts +28 −3
1 1
import { Hono } from "hono";
2 2
import { cors } from "hono/cors";
3 -
import { home, now } from "./routes";
3 +
import { home, now, auth } from "./routes";
4 4
5 -
const app = new Hono();
5 +
interface Env {
6 +
	SESSIONS: KVNamespace;
7 +
	ALLOWED_DID: string;
8 +
	PDS_URL: string;
9 +
	CLIENT_URL: string;
10 +
	API_URL: string;
11 +
}
6 12
7 -
app.use(cors());
13 +
const app = new Hono<{ Bindings: Env }>();
14 +
15 +
// Configure CORS to allow credentials from the client
16 +
app.use(
17 +
	cors({
18 +
		origin: (origin) => {
19 +
			// Allow requests from the client URL and localhost for development
20 +
			const allowedOrigins = [
21 +
				"https://stevedylan.dev",
22 +
				"http://localhost:4321",
23 +
				"http://localhost:3000",
24 +
			];
25 +
			return allowedOrigins.includes(origin) ? origin : allowedOrigins[0];
26 +
		},
27 +
		credentials: true,
28 +
		allowMethods: ["GET", "POST", "OPTIONS"],
29 +
		allowHeaders: ["Content-Type"],
30 +
	}),
31 +
);
8 32
9 33
app.route("/", home);
10 34
app.route("/now", now);
35 +
app.route("/auth", auth);
11 36
12 37
export default app;
packages/server/src/lib/dpop.ts (added) +130 −0
1 +
import * as jose from "jose";
2 +
3 +
export interface DPoPKeyPair {
4 +
	privateKey: CryptoKey;
5 +
	publicKey: CryptoKey;
6 +
	publicJwk: jose.JWK;
7 +
}
8 +
9 +
export interface DPoPProofOptions {
10 +
	method: string;
11 +
	url: string;
12 +
	nonce?: string;
13 +
	accessToken?: string;
14 +
}
15 +
16 +
/**
17 +
 * Generate a new ES256 keypair for DPoP proofs
18 +
 */
19 +
export async function generateDPoPKeyPair(): Promise<DPoPKeyPair> {
20 +
	const keyPair = (await crypto.subtle.generateKey(
21 +
		{
22 +
			name: "ECDSA",
23 +
			namedCurve: "P-256",
24 +
		},
25 +
		true,
26 +
		["sign", "verify"],
27 +
	)) as CryptoKeyPair;
28 +
29 +
	const publicJwk = await jose.exportJWK(keyPair.publicKey);
30 +
31 +
	return {
32 +
		privateKey: keyPair.privateKey,
33 +
		publicKey: keyPair.publicKey,
34 +
		publicJwk,
35 +
	};
36 +
}
37 +
38 +
/**
39 +
 * Export a DPoP keypair to a storable format
40 +
 */
41 +
export async function exportDPoPKeyPair(
42 +
	keyPair: DPoPKeyPair,
43 +
): Promise<{ privateJwk: jose.JWK; publicJwk: jose.JWK }> {
44 +
	const privateJwk = await jose.exportJWK(keyPair.privateKey);
45 +
	return {
46 +
		privateJwk,
47 +
		publicJwk: keyPair.publicJwk,
48 +
	};
49 +
}
50 +
51 +
/**
52 +
 * Import a DPoP keypair from stored JWKs
53 +
 */
54 +
export async function importDPoPKeyPair(stored: {
55 +
	privateJwk: jose.JWK;
56 +
	publicJwk: jose.JWK;
57 +
}): Promise<DPoPKeyPair> {
58 +
	// Use crypto.subtle.importKey with extractable: true
59 +
	// jose.importJWK creates non-extractable keys by default
60 +
	const privateKey = await crypto.subtle.importKey(
61 +
		"jwk",
62 +
		stored.privateJwk as JsonWebKey,
63 +
		{ name: "ECDSA", namedCurve: "P-256" },
64 +
		true, // extractable
65 +
		["sign"],
66 +
	);
67 +
68 +
	const publicKey = await crypto.subtle.importKey(
69 +
		"jwk",
70 +
		stored.publicJwk as JsonWebKey,
71 +
		{ name: "ECDSA", namedCurve: "P-256" },
72 +
		true, // extractable
73 +
		["verify"],
74 +
	);
75 +
76 +
	return {
77 +
		privateKey,
78 +
		publicKey,
79 +
		publicJwk: stored.publicJwk,
80 +
	};
81 +
}
82 +
83 +
/**
84 +
 * Create a DPoP proof JWT for a request
85 +
 */
86 +
export async function createDPoPProof(
87 +
	keyPair: DPoPKeyPair,
88 +
	options: DPoPProofOptions,
89 +
): Promise<string> {
90 +
	const now = Math.floor(Date.now() / 1000);
91 +
	const jti = crypto.randomUUID();
92 +
93 +
	const payload: jose.JWTPayload = {
94 +
		jti,
95 +
		htm: options.method.toUpperCase(),
96 +
		htu: options.url,
97 +
		iat: now,
98 +
	};
99 +
100 +
	// Add nonce if provided (required after first request)
101 +
	if (options.nonce) {
102 +
		payload.nonce = options.nonce;
103 +
	}
104 +
105 +
	// Add access token hash if provided (for resource server requests)
106 +
	if (options.accessToken) {
107 +
		const encoder = new TextEncoder();
108 +
		const data = encoder.encode(options.accessToken);
109 +
		const hashBuffer = await crypto.subtle.digest("SHA-256", data);
110 +
		const hashArray = new Uint8Array(hashBuffer);
111 +
		payload.ath = jose.base64url.encode(hashArray);
112 +
	}
113 +
114 +
	const jwt = await new jose.SignJWT(payload)
115 +
		.setProtectedHeader({
116 +
			alg: "ES256",
117 +
			typ: "dpop+jwt",
118 +
			jwk: keyPair.publicJwk,
119 +
		})
120 +
		.sign(keyPair.privateKey);
121 +
122 +
	return jwt;
123 +
}
124 +
125 +
/**
126 +
 * Extract DPoP nonce from response headers
127 +
 */
128 +
export function extractDPoPNonce(response: Response): string | null {
129 +
	return response.headers.get("DPoP-Nonce");
130 +
}
packages/server/src/lib/oauth.ts (added) +309 −0
1 +
import * as jose from "jose";
2 +
import { type DPoPKeyPair, createDPoPProof, extractDPoPNonce } from "./dpop";
3 +
4 +
export interface OAuthServerMetadata {
5 +
	issuer: string;
6 +
	authorization_endpoint: string;
7 +
	token_endpoint: string;
8 +
	pushed_authorization_request_endpoint: string;
9 +
	scopes_supported?: string[];
10 +
	response_types_supported?: string[];
11 +
	grant_types_supported?: string[];
12 +
	dpop_signing_alg_values_supported?: string[];
13 +
}
14 +
15 +
export interface PKCEPair {
16 +
	codeVerifier: string;
17 +
	codeChallenge: string;
18 +
}
19 +
20 +
export interface TokenResponse {
21 +
	access_token: string;
22 +
	token_type: string;
23 +
	expires_in: number;
24 +
	refresh_token?: string;
25 +
	scope: string;
26 +
	sub: string; // The DID of the authenticated user
27 +
}
28 +
29 +
export interface PARResponse {
30 +
	request_uri: string;
31 +
	expires_in: number;
32 +
}
33 +
34 +
interface OAuthErrorResponse {
35 +
	error: string;
36 +
	error_description?: string;
37 +
}
38 +
39 +
/**
40 +
 * Fetch OAuth server metadata from PDS
41 +
 */
42 +
export async function fetchOAuthMetadata(
43 +
	pdsUrl: string,
44 +
): Promise<OAuthServerMetadata> {
45 +
	const metadataUrl = `${pdsUrl}/.well-known/oauth-authorization-server`;
46 +
	const response = await fetch(metadataUrl);
47 +
48 +
	if (!response.ok) {
49 +
		throw new Error(
50 +
			`Failed to fetch OAuth metadata: ${response.status} ${response.statusText}`,
51 +
		);
52 +
	}
53 +
54 +
	return response.json();
55 +
}
56 +
57 +
/**
58 +
 * Generate PKCE code verifier and challenge
59 +
 */
60 +
export async function generatePKCE(): Promise<PKCEPair> {
61 +
	// Generate 32 random bytes for code verifier
62 +
	const verifierBytes = new Uint8Array(32);
63 +
	crypto.getRandomValues(verifierBytes);
64 +
	const codeVerifier = jose.base64url.encode(verifierBytes);
65 +
66 +
	// Generate S256 challenge
67 +
	const encoder = new TextEncoder();
68 +
	const data = encoder.encode(codeVerifier);
69 +
	const hashBuffer = await crypto.subtle.digest("SHA-256", data);
70 +
	const codeChallenge = jose.base64url.encode(new Uint8Array(hashBuffer));
71 +
72 +
	return { codeVerifier, codeChallenge };
73 +
}
74 +
75 +
/**
76 +
 * Generate a random state token
77 +
 */
78 +
export function generateState(): string {
79 +
	const stateBytes = new Uint8Array(32);
80 +
	crypto.getRandomValues(stateBytes);
81 +
	return jose.base64url.encode(stateBytes);
82 +
}
83 +
84 +
/**
85 +
 * Send a Pushed Authorization Request (PAR) to the PDS
86 +
 */
87 +
export async function sendPAR(
88 +
	metadata: OAuthServerMetadata,
89 +
	clientId: string,
90 +
	redirectUri: string,
91 +
	state: string,
92 +
	pkce: PKCEPair,
93 +
	dpopKeyPair: DPoPKeyPair,
94 +
	scope: string,
95 +
	dpopNonce?: string,
96 +
): Promise<{ parResponse: PARResponse; dpopNonce: string }> {
97 +
	const parEndpoint = metadata.pushed_authorization_request_endpoint;
98 +
99 +
	const params = new URLSearchParams({
100 +
		client_id: clientId,
101 +
		redirect_uri: redirectUri,
102 +
		response_type: "code",
103 +
		state,
104 +
		scope,
105 +
		code_challenge: pkce.codeChallenge,
106 +
		code_challenge_method: "S256",
107 +
	});
108 +
109 +
	// Create DPoP proof for this request
110 +
	const dpopProof = await createDPoPProof(dpopKeyPair, {
111 +
		method: "POST",
112 +
		url: parEndpoint,
113 +
		nonce: dpopNonce,
114 +
	});
115 +
116 +
	const response = await fetch(parEndpoint, {
117 +
		method: "POST",
118 +
		headers: {
119 +
			"Content-Type": "application/x-www-form-urlencoded",
120 +
			DPoP: dpopProof,
121 +
		},
122 +
		body: params.toString(),
123 +
	});
124 +
125 +
	// Handle DPoP nonce requirement
126 +
	const newNonce = extractDPoPNonce(response);
127 +
128 +
	if (response.status === 400 || response.status === 401) {
129 +
		const error: OAuthErrorResponse = await response.json();
130 +
		if (error.error === "use_dpop_nonce" && newNonce) {
131 +
			// Retry with the nonce
132 +
			return sendPAR(
133 +
				metadata,
134 +
				clientId,
135 +
				redirectUri,
136 +
				state,
137 +
				pkce,
138 +
				dpopKeyPair,
139 +
				scope,
140 +
				newNonce,
141 +
			);
142 +
		}
143 +
		throw new Error(`PAR failed: ${error.error_description || error.error}`);
144 +
	}
145 +
146 +
	if (!response.ok) {
147 +
		throw new Error(`PAR failed: ${response.status} ${response.statusText}`);
148 +
	}
149 +
150 +
	const parResponse: PARResponse = await response.json();
151 +
	return {
152 +
		parResponse,
153 +
		dpopNonce: newNonce || dpopNonce || "",
154 +
	};
155 +
}
156 +
157 +
/**
158 +
 * Build the authorization URL for redirecting the user
159 +
 */
160 +
export function buildAuthorizationUrl(
161 +
	metadata: OAuthServerMetadata,
162 +
	requestUri: string,
163 +
	clientId: string,
164 +
): string {
165 +
	const url = new URL(metadata.authorization_endpoint);
166 +
	url.searchParams.set("request_uri", requestUri);
167 +
	url.searchParams.set("client_id", clientId);
168 +
	return url.toString();
169 +
}
170 +
171 +
/**
172 +
 * Exchange authorization code for tokens
173 +
 */
174 +
export async function exchangeCodeForTokens(
175 +
	metadata: OAuthServerMetadata,
176 +
	code: string,
177 +
	codeVerifier: string,
178 +
	clientId: string,
179 +
	redirectUri: string,
180 +
	dpopKeyPair: DPoPKeyPair,
181 +
	dpopNonce?: string,
182 +
): Promise<{ tokenResponse: TokenResponse; dpopNonce: string }> {
183 +
	const tokenEndpoint = metadata.token_endpoint;
184 +
185 +
	const params = new URLSearchParams({
186 +
		grant_type: "authorization_code",
187 +
		code,
188 +
		redirect_uri: redirectUri,
189 +
		client_id: clientId,
190 +
		code_verifier: codeVerifier,
191 +
	});
192 +
193 +
	// Create DPoP proof for token request
194 +
	const dpopProof = await createDPoPProof(dpopKeyPair, {
195 +
		method: "POST",
196 +
		url: tokenEndpoint,
197 +
		nonce: dpopNonce,
198 +
	});
199 +
200 +
	const response = await fetch(tokenEndpoint, {
201 +
		method: "POST",
202 +
		headers: {
203 +
			"Content-Type": "application/x-www-form-urlencoded",
204 +
			DPoP: dpopProof,
205 +
		},
206 +
		body: params.toString(),
207 +
	});
208 +
209 +
	// Handle DPoP nonce requirement
210 +
	const newNonce = extractDPoPNonce(response);
211 +
212 +
	if (response.status === 400 || response.status === 401) {
213 +
		const error: OAuthErrorResponse = await response.json();
214 +
		if (error.error === "use_dpop_nonce" && newNonce) {
215 +
			// Retry with the nonce
216 +
			return exchangeCodeForTokens(
217 +
				metadata,
218 +
				code,
219 +
				codeVerifier,
220 +
				clientId,
221 +
				redirectUri,
222 +
				dpopKeyPair,
223 +
				newNonce,
224 +
			);
225 +
		}
226 +
		throw new Error(
227 +
			`Token exchange failed: ${error.error_description || error.error}`,
228 +
		);
229 +
	}
230 +
231 +
	if (!response.ok) {
232 +
		throw new Error(
233 +
			`Token exchange failed: ${response.status} ${response.statusText}`,
234 +
		);
235 +
	}
236 +
237 +
	const tokenResponse: TokenResponse = await response.json();
238 +
	return {
239 +
		tokenResponse,
240 +
		dpopNonce: newNonce || dpopNonce || "",
241 +
	};
242 +
}
243 +
244 +
/**
245 +
 * Refresh an access token
246 +
 */
247 +
export async function refreshAccessToken(
248 +
	metadata: OAuthServerMetadata,
249 +
	refreshToken: string,
250 +
	clientId: string,
251 +
	dpopKeyPair: DPoPKeyPair,
252 +
	dpopNonce?: string,
253 +
): Promise<{ tokenResponse: TokenResponse; dpopNonce: string }> {
254 +
	const tokenEndpoint = metadata.token_endpoint;
255 +
256 +
	const params = new URLSearchParams({
257 +
		grant_type: "refresh_token",
258 +
		refresh_token: refreshToken,
259 +
		client_id: clientId,
260 +
	});
261 +
262 +
	// Create DPoP proof for token request
263 +
	const dpopProof = await createDPoPProof(dpopKeyPair, {
264 +
		method: "POST",
265 +
		url: tokenEndpoint,
266 +
		nonce: dpopNonce,
267 +
	});
268 +
269 +
	const response = await fetch(tokenEndpoint, {
270 +
		method: "POST",
271 +
		headers: {
272 +
			"Content-Type": "application/x-www-form-urlencoded",
273 +
			DPoP: dpopProof,
274 +
		},
275 +
		body: params.toString(),
276 +
	});
277 +
278 +
	// Handle DPoP nonce requirement
279 +
	const newNonce = extractDPoPNonce(response);
280 +
281 +
	if (response.status === 400 || response.status === 401) {
282 +
		const error: OAuthErrorResponse = await response.json();
283 +
		if (error.error === "use_dpop_nonce" && newNonce) {
284 +
			// Retry with the nonce
285 +
			return refreshAccessToken(
286 +
				metadata,
287 +
				refreshToken,
288 +
				clientId,
289 +
				dpopKeyPair,
290 +
				newNonce,
291 +
			);
292 +
		}
293 +
		throw new Error(
294 +
			`Token refresh failed: ${error.error_description || error.error}`,
295 +
		);
296 +
	}
297 +
298 +
	if (!response.ok) {
299 +
		throw new Error(
300 +
			`Token refresh failed: ${response.status} ${response.statusText}`,
301 +
		);
302 +
	}
303 +
304 +
	const tokenResponse: TokenResponse = await response.json();
305 +
	return {
306 +
		tokenResponse,
307 +
		dpopNonce: newNonce || dpopNonce || "",
308 +
	};
309 +
}
packages/server/src/lib/session.ts (added) +234 −0
1 +
import type { Context } from "hono";
2 +
import type { JWK } from "jose";
3 +
import { exportDPoPKeyPair, importDPoPKeyPair, type DPoPKeyPair } from "./dpop";
4 +
5 +
export interface StoredSession {
6 +
	accessToken: string;
7 +
	refreshToken: string;
8 +
	dpopPrivateJwk: JWK;
9 +
	dpopPublicJwk: JWK;
10 +
	dpopNonce: string;
11 +
	did: string;
12 +
	handle?: string;
13 +
	expiresAt: number; // Unix timestamp
14 +
	createdAt: number;
15 +
}
16 +
17 +
export interface AuthState {
18 +
	codeVerifier: string;
19 +
	state: string;
20 +
	dpopPrivateJwk: JWK;
21 +
	dpopPublicJwk: JWK;
22 +
	dpopNonce: string;
23 +
	createdAt: number;
24 +
}
25 +
26 +
const SESSION_COOKIE_NAME = "session_id";
27 +
const SESSION_TTL = 60 * 60 * 24 * 14; // 14 days in seconds
28 +
const AUTH_STATE_TTL = 60 * 10; // 10 minutes in seconds
29 +
30 +
/**
31 +
 * Generate a unique session ID
32 +
 */
33 +
export function generateSessionId(): string {
34 +
	const bytes = new Uint8Array(32);
35 +
	crypto.getRandomValues(bytes);
36 +
	return Array.from(bytes)
37 +
		.map((b) => b.toString(16).padStart(2, "0"))
38 +
		.join("");
39 +
}
40 +
41 +
/**
42 +
 * Store auth state during OAuth flow
43 +
 */
44 +
export async function storeAuthState(
45 +
	kv: KVNamespace,
46 +
	state: string,
47 +
	codeVerifier: string,
48 +
	dpopKeyPair: DPoPKeyPair,
49 +
	dpopNonce: string,
50 +
): Promise<void> {
51 +
	const exported = await exportDPoPKeyPair(dpopKeyPair);
52 +
53 +
	const authState: AuthState = {
54 +
		codeVerifier,
55 +
		state,
56 +
		dpopPrivateJwk: exported.privateJwk,
57 +
		dpopPublicJwk: exported.publicJwk,
58 +
		dpopNonce,
59 +
		createdAt: Date.now(),
60 +
	};
61 +
62 +
	await kv.put(`auth_state:${state}`, JSON.stringify(authState), {
63 +
		expirationTtl: AUTH_STATE_TTL,
64 +
	});
65 +
}
66 +
67 +
/**
68 +
 * Retrieve and delete auth state
69 +
 */
70 +
export async function getAndDeleteAuthState(
71 +
	kv: KVNamespace,
72 +
	state: string,
73 +
): Promise<{
74 +
	codeVerifier: string;
75 +
	dpopKeyPair: DPoPKeyPair;
76 +
	dpopNonce: string;
77 +
} | null> {
78 +
	const data = await kv.get(`auth_state:${state}`);
79 +
	if (!data) return null;
80 +
81 +
	// Delete the auth state (one-time use)
82 +
	await kv.delete(`auth_state:${state}`);
83 +
84 +
	const authState: AuthState = JSON.parse(data);
85 +
	const dpopKeyPair = await importDPoPKeyPair({
86 +
		privateJwk: authState.dpopPrivateJwk,
87 +
		publicJwk: authState.dpopPublicJwk,
88 +
	});
89 +
90 +
	return {
91 +
		codeVerifier: authState.codeVerifier,
92 +
		dpopKeyPair,
93 +
		dpopNonce: authState.dpopNonce,
94 +
	};
95 +
}
96 +
97 +
/**
98 +
 * Create a new session and store it in KV
99 +
 */
100 +
export async function createSession(
101 +
	kv: KVNamespace,
102 +
	accessToken: string,
103 +
	refreshToken: string,
104 +
	dpopKeyPair: DPoPKeyPair,
105 +
	dpopNonce: string,
106 +
	did: string,
107 +
	expiresIn: number,
108 +
	handle?: string,
109 +
): Promise<string> {
110 +
	const sessionId = generateSessionId();
111 +
	const exported = await exportDPoPKeyPair(dpopKeyPair);
112 +
113 +
	const session: StoredSession = {
114 +
		accessToken,
115 +
		refreshToken,
116 +
		dpopPrivateJwk: exported.privateJwk,
117 +
		dpopPublicJwk: exported.publicJwk,
118 +
		dpopNonce,
119 +
		did,
120 +
		handle,
121 +
		expiresAt: Date.now() + expiresIn * 1000,
122 +
		createdAt: Date.now(),
123 +
	};
124 +
125 +
	await kv.put(`session:${sessionId}`, JSON.stringify(session), {
126 +
		expirationTtl: SESSION_TTL,
127 +
	});
128 +
129 +
	return sessionId;
130 +
}
131 +
132 +
/**
133 +
 * Get session from KV by session ID
134 +
 */
135 +
export async function getSession(
136 +
	kv: KVNamespace,
137 +
	sessionId: string,
138 +
): Promise<{ session: StoredSession; dpopKeyPair: DPoPKeyPair } | null> {
139 +
	const data = await kv.get(`session:${sessionId}`);
140 +
	if (!data) return null;
141 +
142 +
	const session: StoredSession = JSON.parse(data);
143 +
	const dpopKeyPair = await importDPoPKeyPair({
144 +
		privateJwk: session.dpopPrivateJwk,
145 +
		publicJwk: session.dpopPublicJwk,
146 +
	});
147 +
148 +
	return { session, dpopKeyPair };
149 +
}
150 +
151 +
/**
152 +
 * Update session with new tokens
153 +
 */
154 +
export async function updateSession(
155 +
	kv: KVNamespace,
156 +
	sessionId: string,
157 +
	accessToken: string,
158 +
	refreshToken: string,
159 +
	dpopNonce: string,
160 +
	expiresIn: number,
161 +
): Promise<void> {
162 +
	const data = await kv.get(`session:${sessionId}`);
163 +
	if (!data) throw new Error("Session not found");
164 +
165 +
	const session: StoredSession = JSON.parse(data);
166 +
	session.accessToken = accessToken;
167 +
	session.refreshToken = refreshToken;
168 +
	session.dpopNonce = dpopNonce;
169 +
	session.expiresAt = Date.now() + expiresIn * 1000;
170 +
171 +
	await kv.put(`session:${sessionId}`, JSON.stringify(session), {
172 +
		expirationTtl: SESSION_TTL,
173 +
	});
174 +
}
175 +
176 +
/**
177 +
 * Delete a session
178 +
 */
179 +
export async function deleteSession(
180 +
	kv: KVNamespace,
181 +
	sessionId: string,
182 +
): Promise<void> {
183 +
	await kv.delete(`session:${sessionId}`);
184 +
}
185 +
186 +
/**
187 +
 * Get session ID from cookie
188 +
 */
189 +
export function getSessionIdFromCookie(c: Context): string | null {
190 +
	const cookie = c.req.header("Cookie");
191 +
	if (!cookie) return null;
192 +
193 +
	const match = cookie.match(new RegExp(`${SESSION_COOKIE_NAME}=([^;]+)`));
194 +
	return match ? match[1] : null;
195 +
}
196 +
197 +
/**
198 +
 * Set session cookie in response
199 +
 */
200 +
export function setSessionCookie(
201 +
	c: Context,
202 +
	sessionId: string,
203 +
	clientUrl: string,
204 +
): void {
205 +
	const isLocalhost = clientUrl.includes("localhost");
206 +
	const domain = isLocalhost ? "" : "; Domain=.stevedylan.dev";
207 +
	const secure = isLocalhost ? "" : "; Secure";
208 +
209 +
	c.header(
210 +
		"Set-Cookie",
211 +
		`${SESSION_COOKIE_NAME}=${sessionId}; HttpOnly; SameSite=Lax; Path=/${domain}${secure}; Max-Age=${SESSION_TTL}`,
212 +
	);
213 +
}
214 +
215 +
/**
216 +
 * Clear session cookie
217 +
 */
218 +
export function clearSessionCookie(c: Context, clientUrl: string): void {
219 +
	const isLocalhost = clientUrl.includes("localhost");
220 +
	const domain = isLocalhost ? "" : "; Domain=.stevedylan.dev";
221 +
	const secure = isLocalhost ? "" : "; Secure";
222 +
223 +
	c.header(
224 +
		"Set-Cookie",
225 +
		`${SESSION_COOKIE_NAME}=; HttpOnly; SameSite=Lax; Path=/${domain}${secure}; Max-Age=0`,
226 +
	);
227 +
}
228 +
229 +
/**
230 +
 * Check if token is expired or about to expire (within 1 minute)
231 +
 */
232 +
export function isTokenExpired(expiresAt: number): boolean {
233 +
	return Date.now() > expiresAt - 60000;
234 +
}
packages/server/src/routes/auth.ts (added) +245 −0
1 +
import { Hono } from "hono";
2 +
import { generateDPoPKeyPair } from "../lib/dpop";
3 +
import {
4 +
	fetchOAuthMetadata,
5 +
	generatePKCE,
6 +
	generateState,
7 +
	sendPAR,
8 +
	buildAuthorizationUrl,
9 +
	exchangeCodeForTokens,
10 +
} from "../lib/oauth";
11 +
import {
12 +
	storeAuthState,
13 +
	getAndDeleteAuthState,
14 +
	createSession,
15 +
	getSession,
16 +
	deleteSession,
17 +
	getSessionIdFromCookie,
18 +
	setSessionCookie,
19 +
	clearSessionCookie,
20 +
	isTokenExpired,
21 +
	updateSession,
22 +
} from "../lib/session";
23 +
import { refreshAccessToken } from "../lib/oauth";
24 +
25 +
interface Env {
26 +
	SESSIONS: KVNamespace;
27 +
	ALLOWED_DID: string;
28 +
	PDS_URL: string;
29 +
	CLIENT_URL: string;
30 +
	API_URL: string;
31 +
}
32 +
33 +
const auth = new Hono<{ Bindings: Env }>();
34 +
35 +
// OAuth client metadata endpoint
36 +
auth.get("/client-metadata.json", (c) => {
37 +
	const clientId = `${c.env.API_URL}/auth/client-metadata.json`;
38 +
	const redirectUri = `${c.env.API_URL}/auth/callback`;
39 +
40 +
	return c.json({
41 +
		client_id: clientId,
42 +
		client_name: "Steve Dylan's Blog",
43 +
		client_uri: c.env.API_URL,
44 +
		redirect_uris: [redirectUri],
45 +
		grant_types: ["authorization_code", "refresh_token"],
46 +
		response_types: ["code"],
47 +
		scope: "atproto transition:generic",
48 +
		token_endpoint_auth_method: "none",
49 +
		application_type: "web",
50 +
		dpop_bound_access_tokens: true,
51 +
	});
52 +
});
53 +
54 +
// Start OAuth login flow
55 +
auth.get("/login", async (c) => {
56 +
	try {
57 +
		const clientId = `${c.env.API_URL}/auth/client-metadata.json`;
58 +
		const redirectUri = `${c.env.API_URL}/auth/callback`;
59 +
60 +
		// Fetch OAuth metadata from PDS
61 +
		const metadata = await fetchOAuthMetadata(c.env.PDS_URL);
62 +
63 +
		// Generate PKCE and state
64 +
		const pkce = await generatePKCE();
65 +
		const state = generateState();
66 +
67 +
		// Generate DPoP keypair
68 +
		const dpopKeyPair = await generateDPoPKeyPair();
69 +
70 +
		// Send PAR request
71 +
		const { parResponse, dpopNonce } = await sendPAR(
72 +
			metadata,
73 +
			clientId,
74 +
			redirectUri,
75 +
			state,
76 +
			pkce,
77 +
			dpopKeyPair,
78 +
			"atproto transition:generic",
79 +
		);
80 +
81 +
		// Store auth state in KV
82 +
		await storeAuthState(
83 +
			c.env.SESSIONS,
84 +
			state,
85 +
			pkce.codeVerifier,
86 +
			dpopKeyPair,
87 +
			dpopNonce,
88 +
		);
89 +
90 +
		// Build authorization URL and redirect
91 +
		const authUrl = buildAuthorizationUrl(
92 +
			metadata,
93 +
			parResponse.request_uri,
94 +
			clientId,
95 +
		);
96 +
		return c.redirect(authUrl);
97 +
	} catch (error) {
98 +
		console.error("Login error:", error);
99 +
		return c.redirect(`${c.env.CLIENT_URL}/now?error=login_failed`);
100 +
	}
101 +
});
102 +
103 +
// OAuth callback handler
104 +
auth.get("/callback", async (c) => {
105 +
	try {
106 +
		const code = c.req.query("code");
107 +
		const state = c.req.query("state");
108 +
		const error = c.req.query("error");
109 +
		const errorDescription = c.req.query("error_description");
110 +
111 +
		// Handle OAuth errors
112 +
		if (error) {
113 +
			console.error("OAuth error:", error, errorDescription);
114 +
			return c.redirect(
115 +
				`${c.env.CLIENT_URL}/now?error=${encodeURIComponent(error)}`,
116 +
			);
117 +
		}
118 +
119 +
		if (!code || !state) {
120 +
			return c.redirect(`${c.env.CLIENT_URL}/now?error=missing_params`);
121 +
		}
122 +
123 +
		// Retrieve and validate auth state
124 +
		const authState = await getAndDeleteAuthState(c.env.SESSIONS, state);
125 +
		if (!authState) {
126 +
			return c.redirect(`${c.env.CLIENT_URL}/now?error=invalid_state`);
127 +
		}
128 +
129 +
		const clientId = `${c.env.API_URL}/auth/client-metadata.json`;
130 +
		const redirectUri = `${c.env.API_URL}/auth/callback`;
131 +
132 +
		// Fetch OAuth metadata
133 +
		const metadata = await fetchOAuthMetadata(c.env.PDS_URL);
134 +
135 +
		// Exchange code for tokens
136 +
		const { tokenResponse, dpopNonce } = await exchangeCodeForTokens(
137 +
			metadata,
138 +
			code,
139 +
			authState.codeVerifier,
140 +
			clientId,
141 +
			redirectUri,
142 +
			authState.dpopKeyPair,
143 +
			authState.dpopNonce,
144 +
		);
145 +
146 +
		// CRITICAL: Only allow the site owner
147 +
		if (tokenResponse.sub !== c.env.ALLOWED_DID) {
148 +
			console.error("Unauthorized login attempt from:", tokenResponse.sub);
149 +
			return c.redirect(`${c.env.CLIENT_URL}/now?error=unauthorized`);
150 +
		}
151 +
152 +
		// Create session
153 +
		const sessionId = await createSession(
154 +
			c.env.SESSIONS,
155 +
			tokenResponse.access_token,
156 +
			tokenResponse.refresh_token || "",
157 +
			authState.dpopKeyPair,
158 +
			dpopNonce,
159 +
			tokenResponse.sub,
160 +
			tokenResponse.expires_in,
161 +
		);
162 +
163 +
		// Set session cookie and redirect to /now
164 +
		setSessionCookie(c, sessionId, c.env.CLIENT_URL);
165 +
		return c.redirect(`${c.env.CLIENT_URL}/now/post`);
166 +
	} catch (error) {
167 +
		console.error("Callback error:", error);
168 +
		return c.redirect(`${c.env.CLIENT_URL}/now?error=callback_failed`);
169 +
	}
170 +
});
171 +
172 +
// Logout endpoint
173 +
auth.post("/logout", async (c) => {
174 +
	const sessionId = getSessionIdFromCookie(c);
175 +
176 +
	if (sessionId) {
177 +
		await deleteSession(c.env.SESSIONS, sessionId);
178 +
	}
179 +
180 +
	clearSessionCookie(c, c.env.CLIENT_URL);
181 +
182 +
	return c.json({ success: true });
183 +
});
184 +
185 +
// Check auth status
186 +
auth.get("/status", async (c) => {
187 +
	const sessionId = getSessionIdFromCookie(c);
188 +
189 +
	if (!sessionId) {
190 +
		return c.json({ authenticated: false });
191 +
	}
192 +
193 +
	const sessionData = await getSession(c.env.SESSIONS, sessionId);
194 +
	if (!sessionData) {
195 +
		clearSessionCookie(c, c.env.CLIENT_URL);
196 +
		return c.json({ authenticated: false });
197 +
	}
198 +
199 +
	const { session, dpopKeyPair } = sessionData;
200 +
201 +
	// Check if token needs refresh
202 +
	if (isTokenExpired(session.expiresAt) && session.refreshToken) {
203 +
		try {
204 +
			const metadata = await fetchOAuthMetadata(c.env.PDS_URL);
205 +
			const clientId = `${c.env.API_URL}/auth/client-metadata.json`;
206 +
207 +
			const { tokenResponse, dpopNonce } = await refreshAccessToken(
208 +
				metadata,
209 +
				session.refreshToken,
210 +
				clientId,
211 +
				dpopKeyPair,
212 +
				session.dpopNonce,
213 +
			);
214 +
215 +
			// Update session with new tokens
216 +
			await updateSession(
217 +
				c.env.SESSIONS,
218 +
				sessionId,
219 +
				tokenResponse.access_token,
220 +
				tokenResponse.refresh_token || session.refreshToken,
221 +
				dpopNonce,
222 +
				tokenResponse.expires_in,
223 +
			);
224 +
225 +
			return c.json({
226 +
				authenticated: true,
227 +
				did: session.did,
228 +
				handle: session.handle,
229 +
			});
230 +
		} catch (error) {
231 +
			console.error("Token refresh failed:", error);
232 +
			await deleteSession(c.env.SESSIONS, sessionId);
233 +
			clearSessionCookie(c, c.env.CLIENT_URL);
234 +
			return c.json({ authenticated: false });
235 +
		}
236 +
	}
237 +
238 +
	return c.json({
239 +
		authenticated: true,
240 +
		did: session.did,
241 +
		handle: session.handle,
242 +
	});
243 +
});
244 +
245 +
export default auth;
packages/server/src/routes/index.ts +1 −0
1 1
export { default as home } from "./home";
2 2
export { default as now } from "./now";
3 +
export { default as auth } from "./auth";
packages/server/src/routes/now.ts +142 −1
1 1
import { Hono } from "hono";
2 2
import { Feed } from "feed";
3 3
import type { ListRecordsResponse } from "../types";
4 +
import { createDPoPProof, extractDPoPNonce } from "../lib/dpop";
5 +
import { fetchOAuthMetadata, refreshAccessToken } from "../lib/oauth";
6 +
import {
7 +
	getSession,
8 +
	getSessionIdFromCookie,
9 +
	isTokenExpired,
10 +
	updateSession,
11 +
} from "../lib/session";
4 12
5 -
const now = new Hono();
13 +
interface Env {
14 +
	SESSIONS: KVNamespace;
15 +
	ALLOWED_DID: string;
16 +
	PDS_URL: string;
17 +
	CLIENT_URL: string;
18 +
	API_URL: string;
19 +
}
20 +
21 +
const now = new Hono<{ Bindings: Env }>();
6 22
7 23
const DID = "did:plc:ia2zdnhjaokf5lazhxrmj6eu";
8 24
const PDS_URL = "https://polybius.social";
25 +
26 +
// Create a new post
27 +
now.post("/post", async (c) => {
28 +
	try {
29 +
		// Get session from cookie
30 +
		const sessionId = getSessionIdFromCookie(c);
31 +
		if (!sessionId) {
32 +
			return c.json({ error: "Not authenticated" }, 401);
33 +
		}
34 +
35 +
		const sessionData = await getSession(c.env.SESSIONS, sessionId);
36 +
		if (!sessionData) {
37 +
			return c.json({ error: "Session not found" }, 401);
38 +
		}
39 +
40 +
		let { session, dpopKeyPair } = sessionData;
41 +
42 +
		// Refresh token if expired
43 +
		if (isTokenExpired(session.expiresAt) && session.refreshToken) {
44 +
			const metadata = await fetchOAuthMetadata(c.env.PDS_URL);
45 +
			const clientId = `${c.env.API_URL}/auth/client-metadata.json`;
46 +
47 +
			const { tokenResponse, dpopNonce } = await refreshAccessToken(
48 +
				metadata,
49 +
				session.refreshToken,
50 +
				clientId,
51 +
				dpopKeyPair,
52 +
				session.dpopNonce,
53 +
			);
54 +
55 +
			// Update session with new tokens
56 +
			await updateSession(
57 +
				c.env.SESSIONS,
58 +
				sessionId,
59 +
				tokenResponse.access_token,
60 +
				tokenResponse.refresh_token || session.refreshToken,
61 +
				dpopNonce,
62 +
				tokenResponse.expires_in,
63 +
			);
64 +
65 +
			// Update local session object
66 +
			session.accessToken = tokenResponse.access_token;
67 +
			session.dpopNonce = dpopNonce;
68 +
		}
69 +
70 +
		// Parse request body
71 +
		const body = await c.req.json<{ text: string }>();
72 +
		if (!body.text || body.text.trim().length === 0) {
73 +
			return c.json({ error: "Post text is required" }, 400);
74 +
		}
75 +
76 +
		if (body.text.length > 300) {
77 +
			return c.json({ error: "Post text must be 300 characters or less" }, 400);
78 +
		}
79 +
80 +
		// Create the post record
81 +
		const createRecordUrl = `${c.env.PDS_URL}/xrpc/com.atproto.repo.createRecord`;
82 +
83 +
		const postRecord = {
84 +
			repo: session.did,
85 +
			collection: "app.bsky.feed.post",
86 +
			record: {
87 +
				$type: "app.bsky.feed.post",
88 +
				text: body.text.trim(),
89 +
				createdAt: new Date().toISOString(),
90 +
			},
91 +
		};
92 +
93 +
		// Make request with DPoP
94 +
		const makeRequest = async (nonce?: string): Promise<Response> => {
95 +
			const dpopProof = await createDPoPProof(dpopKeyPair, {
96 +
				method: "POST",
97 +
				url: createRecordUrl,
98 +
				nonce: nonce || session.dpopNonce,
99 +
				accessToken: session.accessToken,
100 +
			});
101 +
102 +
			return fetch(createRecordUrl, {
103 +
				method: "POST",
104 +
				headers: {
105 +
					"Content-Type": "application/json",
106 +
					Authorization: `DPoP ${session.accessToken}`,
107 +
					DPoP: dpopProof,
108 +
				},
109 +
				body: JSON.stringify(postRecord),
110 +
			});
111 +
		};
112 +
113 +
		let response = await makeRequest();
114 +
115 +
		// Handle DPoP nonce requirement
116 +
		if (response.status === 401) {
117 +
			const newNonce = extractDPoPNonce(response);
118 +
			if (newNonce) {
119 +
				// Retry with new nonce
120 +
				response = await makeRequest(newNonce);
121 +
122 +
				// Update session with new nonce
123 +
				await updateSession(
124 +
					c.env.SESSIONS,
125 +
					sessionId,
126 +
					session.accessToken,
127 +
					session.refreshToken,
128 +
					newNonce,
129 +
					Math.floor((session.expiresAt - Date.now()) / 1000),
130 +
				);
131 +
			}
132 +
		}
133 +
134 +
		if (!response.ok) {
135 +
			const errorData = await response.json();
136 +
			console.error("Failed to create post:", errorData);
137 +
			return c.json(
138 +
				{ error: "Failed to create post", details: errorData },
139 +
				response.status as 400 | 401 | 403 | 500,
140 +
			);
141 +
		}
142 +
143 +
		const result = (await response.json()) as { uri: string; cid: string };
144 +
		return c.json({ success: true, uri: result.uri, cid: result.cid });
145 +
	} catch (error) {
146 +
		console.error("Error creating post:", error);
147 +
		return c.json({ error: "Internal server error" }, 500);
148 +
	}
149 +
});
9 150
10 151
now.get("/rss", async (c) => {
11 152
	try {
packages/server/wrangler.jsonc +13 −30
3 3
	"name": "api-stevedylan-dev",
4 4
	"main": "src/index.ts",
5 5
	"compatibility_date": "2026-01-04",
6 -
	"compatibility_flags": ["nodejs_compat"]
7 -
	// "vars": {
8 -
	//   "MY_VAR": "my-variable"
9 -
	// },
10 -
	// "kv_namespaces": [
11 -
	//   {
12 -
	//     "binding": "MY_KV_NAMESPACE",
13 -
	//     "id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
14 -
	//   }
15 -
	// ],
16 -
	// "r2_buckets": [
17 -
	//   {
18 -
	//     "binding": "MY_BUCKET",
19 -
	//     "bucket_name": "my-bucket"
20 -
	//   }
21 -
	// ],
22 -
	// "d1_databases": [
23 -
	//   {
24 -
	//     "binding": "MY_DB",
25 -
	//     "database_name": "my-database",
26 -
	//     "database_id": ""
27 -
	//   }
28 -
	// ],
29 -
	// "ai": {
30 -
	//   "binding": "AI"
31 -
	// },
32 -
	// "observability": {
33 -
	//   "enabled": true,
34 -
	//   "head_sampling_rate": 1
35 -
	// }
6 +
	"compatibility_flags": ["nodejs_compat"],
7 +
	"vars": {
8 +
		"ALLOWED_DID": "did:plc:ia2zdnhjaokf5lazhxrmj6eu",
9 +
		"PDS_URL": "https://polybius.social",
10 +
		"CLIENT_URL": "https://stevedylan.dev",
11 +
		"API_URL": "https://api.stevedylan.dev"
12 +
	},
13 +
	"kv_namespaces": [
14 +
		{
15 +
			"binding": "SESSIONS",
16 +
			"id": "796e0c7a685242aa83f7bbe15710d73f"
17 +
		}
18 +
	]
36 19
}