feat: added OAuth and posting to ATProto
3533bcfa
17 file(s) · +1429 −73
| 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 | ||
| 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 | }, |
|
| 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 | + | } |
| 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 | + | } |
| 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 | + | } |
| 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", |
| 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> |
| 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 | } |
| 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", |
| 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; |
| 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 | + | } |
| 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 | + | } |
| 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 | + | } |
| 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; |
| 1 | 1 | export { default as home } from "./home"; |
|
| 2 | 2 | export { default as now } from "./now"; |
|
| 3 | + | export { default as auth } from "./auth"; |
| 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 { |
| 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 | } |