Framework-agnostic OAuth integration for AT Protocol (Bluesky) applications.

Initial release of @tijs/atproto-oauth v0.1.0

Framework-agnostic OAuth integration for AT Protocol applications.
Works with standard Web Request/Response APIs.

tijs.org dc260fee

+1612
+9
.gitignore
··· 1 + # Deno 2 + deno.lock 3 + 4 + # IDE 5 + .vscode/ 6 + .idea/ 7 + 8 + # OS 9 + .DS_Store
+21
CHANGELOG.md
··· 1 + # Changelog 2 + 3 + All notable changes to this project will be documented in this file. 4 + 5 + ## [0.1.0] - 2025-11-27 6 + 7 + ### Added 8 + 9 + - Initial release 10 + - `createATProtoOAuth()` factory function for complete OAuth integration 11 + - Framework-agnostic route handlers using standard Request/Response APIs: 12 + - `handleLogin()` - Start OAuth flow 13 + - `handleCallback()` - Complete OAuth flow 14 + - `handleClientMetadata()` - Serve OAuth client metadata 15 + - `handleLogout()` - Log out and clear session 16 + - `getSessionFromRequest()` for getting authenticated sessions with cookie 17 + refresh 18 + - `OAuthSessions` class for direct session management 19 + - Support for both web (cookie) and mobile (Bearer token) authentication 20 + - Automatic token refresh via `@tijs/oauth-client-deno` 21 + - Type exports for `SessionInterface`, `ATProtoOAuthConfig`, etc.
+21
LICENSE
··· 1 + MIT License 2 + 3 + Copyright (c) 2025 Tijs Teulings 4 + 5 + Permission is hereby granted, free of charge, to any person obtaining a copy 6 + of this software and associated documentation files (the "Software"), to deal 7 + in the Software without restriction, including without limitation the rights 8 + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 + copies of the Software, and to permit persons to whom the Software is 10 + furnished to do so, subject to the following conditions: 11 + 12 + The above copyright notice and this permission notice shall be included in all 13 + copies or substantial portions of the Software. 14 + 15 + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 + SOFTWARE.
+200
README.md
··· 1 + # @tijs/atproto-oauth 2 + 3 + Framework-agnostic OAuth integration for AT Protocol (Bluesky) applications. 4 + Works with standard Web Request/Response APIs - no framework dependencies. 5 + 6 + ## Installation 7 + 8 + ```typescript 9 + import { createATProtoOAuth } from "jsr:@tijs/atproto-oauth"; 10 + import { SQLiteStorage, valTownAdapter } from "jsr:@tijs/atproto-storage"; 11 + ``` 12 + 13 + ## Usage 14 + 15 + ### Basic Setup 16 + 17 + ```typescript 18 + import { createATProtoOAuth } from "jsr:@tijs/atproto-oauth"; 19 + import { SQLiteStorage, valTownAdapter } from "jsr:@tijs/atproto-storage"; 20 + import { sqlite } from "https://esm.town/v/std/sqlite"; 21 + 22 + const oauth = createATProtoOAuth({ 23 + baseUrl: "https://myapp.example.com", 24 + appName: "My App", 25 + cookieSecret: Deno.env.get("COOKIE_SECRET")!, 26 + storage: new SQLiteStorage(valTownAdapter(sqlite)), 27 + sessionTtl: 60 * 60 * 24 * 14, // 14 days 28 + }); 29 + ``` 30 + 31 + ### Hono Integration 32 + 33 + ```typescript 34 + import { Hono } from "hono"; 35 + 36 + const app = new Hono(); 37 + 38 + // Mount OAuth routes 39 + app.get("/login", (c) => oauth.handleLogin(c.req.raw)); 40 + app.get("/oauth/callback", (c) => oauth.handleCallback(c.req.raw)); 41 + app.get("/oauth-client-metadata.json", () => oauth.handleClientMetadata()); 42 + app.post("/api/auth/logout", (c) => oauth.handleLogout(c.req.raw)); 43 + 44 + // Protected route example 45 + app.get("/api/profile", async (c) => { 46 + const { session, setCookieHeader, error } = await oauth.getSessionFromRequest( 47 + c.req.raw, 48 + ); 49 + 50 + if (!session) { 51 + return c.json({ error: error?.message || "Not authenticated" }, 401); 52 + } 53 + 54 + // Make authenticated API call 55 + const response = await session.makeRequest( 56 + "GET", 57 + `${session.pdsUrl}/xrpc/app.bsky.actor.getProfile?actor=${session.did}`, 58 + ); 59 + 60 + const profile = await response.json(); 61 + 62 + const res = c.json(profile); 63 + if (setCookieHeader) { 64 + res.headers.set("Set-Cookie", setCookieHeader); 65 + } 66 + return res; 67 + }); 68 + ``` 69 + 70 + ### Fresh (Deno) Integration 71 + 72 + ```typescript 73 + // routes/login.ts 74 + export const handler = async (req: Request) => { 75 + return oauth.handleLogin(req); 76 + }; 77 + 78 + // routes/oauth/callback.ts 79 + export const handler = async (req: Request) => { 80 + return oauth.handleCallback(req); 81 + }; 82 + ``` 83 + 84 + ### Direct Access to Sessions 85 + 86 + For advanced use cases, you can access the sessions manager directly: 87 + 88 + ```typescript 89 + // Get session by DID 90 + const session = await oauth.sessions.getOAuthSession(did); 91 + if (session) { 92 + const response = await session.makeRequest("GET", url); 93 + } 94 + 95 + // Save session manually 96 + await oauth.sessions.saveOAuthSession(session); 97 + 98 + // Delete session 99 + await oauth.sessions.deleteOAuthSession(did); 100 + ``` 101 + 102 + ## Configuration 103 + 104 + ```typescript 105 + interface ATProtoOAuthConfig { 106 + /** Base URL of your application */ 107 + baseUrl: string; 108 + 109 + /** Display name for OAuth consent screen */ 110 + appName: string; 111 + 112 + /** Cookie signing secret (at least 32 characters) */ 113 + cookieSecret: string; 114 + 115 + /** Storage implementation for OAuth sessions */ 116 + storage: OAuthStorage; 117 + 118 + /** Session TTL in seconds (default: 7 days) */ 119 + sessionTtl?: number; 120 + 121 + /** URL to app logo for OAuth consent screen */ 122 + logoUri?: string; 123 + 124 + /** URL to privacy policy */ 125 + policyUri?: string; 126 + 127 + /** OAuth scope (default: "atproto transition:generic") */ 128 + scope?: string; 129 + 130 + /** Mobile app callback scheme (default: "app://auth-callback") */ 131 + mobileScheme?: string; 132 + 133 + /** Logger for debugging (default: no-op) */ 134 + logger?: Logger; 135 + } 136 + ``` 137 + 138 + ## API 139 + 140 + ### `createATProtoOAuth(config)` 141 + 142 + Creates an OAuth instance with the following methods: 143 + 144 + - `handleLogin(request)` - Start OAuth flow (redirect to provider) 145 + - `handleCallback(request)` - Complete OAuth flow (handle callback) 146 + - `handleClientMetadata()` - Return OAuth client metadata JSON 147 + - `handleLogout(request)` - Log out and clear session 148 + - `getSessionFromRequest(request)` - Get authenticated session from request 149 + - `getClientMetadata()` - Get client metadata object 150 + - `sessions` - Access to session management interface 151 + 152 + ### Session Result 153 + 154 + `getSessionFromRequest()` returns: 155 + 156 + ```typescript 157 + { 158 + session: SessionInterface | null; 159 + setCookieHeader?: string; // Set this on response to refresh session 160 + error?: { 161 + type: "NO_COOKIE" | "INVALID_COOKIE" | "SESSION_EXPIRED" | "OAUTH_ERROR" | "UNKNOWN"; 162 + message: string; 163 + details?: unknown; 164 + }; 165 + } 166 + ``` 167 + 168 + ### SessionInterface 169 + 170 + The session object provides: 171 + 172 + ```typescript 173 + interface SessionInterface { 174 + did: string; // User's DID 175 + handle?: string; // User's handle 176 + pdsUrl: string; // User's PDS URL 177 + accessToken: string; 178 + refreshToken?: string; 179 + 180 + // Make authenticated requests with automatic DPoP handling 181 + makeRequest( 182 + method: string, 183 + url: string, 184 + options?: RequestInit, 185 + ): Promise<Response>; 186 + } 187 + ``` 188 + 189 + ## Related Packages 190 + 191 + - [@tijs/atproto-storage](https://jsr.io/@tijs/atproto-storage) - Storage 192 + implementations 193 + - [@tijs/atproto-sessions](https://jsr.io/@tijs/atproto-sessions) - Session 194 + cookie management 195 + - [@tijs/oauth-client-deno](https://jsr.io/@tijs/oauth-client-deno) - AT 196 + Protocol OAuth client 197 + 198 + ## License 199 + 200 + MIT
+30
deno.json
··· 1 + { 2 + "$schema": "https://jsr.io/schema/config-file.v1.json", 3 + "name": "@tijs/atproto-oauth", 4 + "version": "0.1.0", 5 + "license": "MIT", 6 + "exports": "./mod.ts", 7 + "publish": { 8 + "include": ["mod.ts", "src/**/*.ts", "README.md", "LICENSE"], 9 + "exclude": ["**/*.test.ts"] 10 + }, 11 + "imports": { 12 + "@std/assert": "jsr:@std/assert@1.0.16", 13 + "@tijs/oauth-client-deno": "jsr:@tijs/oauth-client-deno@4.0.2", 14 + "@tijs/atproto-sessions": "jsr:@tijs/atproto-sessions@0.1.1", 15 + "@tijs/atproto-storage": "jsr:@tijs/atproto-storage@0.1.1", 16 + "@atproto/syntax": "npm:@atproto/syntax@0.3.0" 17 + }, 18 + "compilerOptions": { 19 + "strict": true, 20 + "noImplicitAny": true 21 + }, 22 + "tasks": { 23 + "test": "deno test --allow-all src/", 24 + "check": "deno check mod.ts", 25 + "fmt": "deno fmt", 26 + "lint": "deno lint", 27 + "quality": "deno fmt && deno lint && deno check mod.ts", 28 + "ci": "deno task quality && deno task test" 29 + } 30 + }
+57
mod.ts
··· 1 + /** 2 + * @module atproto-oauth 3 + * 4 + * Framework-agnostic OAuth integration for AT Protocol applications. 5 + * 6 + * Provides complete OAuth flow handling for Bluesky/ATProto authentication 7 + * using standard Web Request/Response APIs. Works with any framework. 8 + * 9 + * @example 10 + * ```typescript 11 + * import { createATProtoOAuth } from "@tijs/atproto-oauth"; 12 + * import { SQLiteStorage, valTownAdapter } from "@tijs/atproto-storage"; 13 + * 14 + * const oauth = createATProtoOAuth({ 15 + * baseUrl: "https://myapp.example.com", 16 + * appName: "My App", 17 + * cookieSecret: Deno.env.get("COOKIE_SECRET")!, 18 + * storage: new SQLiteStorage(valTownAdapter(sqlite)), 19 + * }); 20 + * 21 + * // Mount routes in your framework 22 + * app.get("/login", (c) => oauth.handleLogin(c.req.raw)); 23 + * app.get("/oauth/callback", (c) => oauth.handleCallback(c.req.raw)); 24 + * app.get("/oauth-client-metadata.json", () => oauth.handleClientMetadata()); 25 + * 26 + * // Get session in protected routes 27 + * const { session, setCookieHeader } = await oauth.getSessionFromRequest(request); 28 + * ``` 29 + */ 30 + 31 + // Main factory function 32 + export { createATProtoOAuth } from "./src/oauth.ts"; 33 + 34 + // Session management 35 + export { OAuthSessions } from "./src/sessions.ts"; 36 + 37 + // Client metadata 38 + export { generateClientMetadata } from "./src/client-metadata.ts"; 39 + 40 + // Types 41 + export type { 42 + ATProtoOAuthConfig, 43 + ATProtoOAuthInstance, 44 + ClientMetadata, 45 + Logger, 46 + MobileOAuthStartRequest, 47 + MobileOAuthStartResponse, 48 + OAuthClientInterface, 49 + OAuthSessionFromRequestResult, 50 + OAuthSessionsInterface, 51 + OAuthState, 52 + OAuthStorage, 53 + SessionData, 54 + SessionInterface, 55 + SessionValidationResult, 56 + StoredOAuthSession, 57 + } from "./src/types.ts";
+75
src/client-metadata.test.ts
··· 1 + import { assertEquals } from "@std/assert"; 2 + import { generateClientMetadata } from "./client-metadata.ts"; 3 + import type { ATProtoOAuthConfig } from "./types.ts"; 4 + import { MemoryStorage } from "@tijs/atproto-storage"; 5 + 6 + Deno.test("generateClientMetadata - basic config", () => { 7 + const config: ATProtoOAuthConfig = { 8 + baseUrl: "https://myapp.example.com", 9 + appName: "Test App", 10 + cookieSecret: "a".repeat(32), 11 + storage: new MemoryStorage(), 12 + }; 13 + 14 + const metadata = generateClientMetadata(config); 15 + 16 + assertEquals(metadata.client_name, "Test App"); 17 + assertEquals( 18 + metadata.client_id, 19 + "https://myapp.example.com/oauth-client-metadata.json", 20 + ); 21 + assertEquals(metadata.client_uri, "https://myapp.example.com"); 22 + assertEquals(metadata.redirect_uris, [ 23 + "https://myapp.example.com/oauth/callback", 24 + ]); 25 + assertEquals(metadata.scope, "atproto transition:generic"); 26 + assertEquals(metadata.grant_types, ["authorization_code", "refresh_token"]); 27 + assertEquals(metadata.response_types, ["code"]); 28 + assertEquals(metadata.application_type, "web"); 29 + assertEquals(metadata.token_endpoint_auth_method, "none"); 30 + assertEquals(metadata.dpop_bound_access_tokens, true); 31 + assertEquals(metadata.logo_uri, undefined); 32 + assertEquals(metadata.policy_uri, undefined); 33 + }); 34 + 35 + Deno.test("generateClientMetadata - with optional fields", () => { 36 + const config: ATProtoOAuthConfig = { 37 + baseUrl: "https://myapp.example.com/", 38 + appName: "Test App", 39 + cookieSecret: "a".repeat(32), 40 + storage: new MemoryStorage(), 41 + logoUri: "https://myapp.example.com/logo.png", 42 + policyUri: "https://myapp.example.com/privacy", 43 + scope: "atproto transition:generic transition:chat.bsky", 44 + }; 45 + 46 + const metadata = generateClientMetadata(config); 47 + 48 + assertEquals(metadata.client_uri, "https://myapp.example.com"); // trailing slash removed 49 + assertEquals(metadata.logo_uri, "https://myapp.example.com/logo.png"); 50 + assertEquals(metadata.policy_uri, "https://myapp.example.com/privacy"); 51 + assertEquals( 52 + metadata.scope, 53 + "atproto transition:generic transition:chat.bsky", 54 + ); 55 + }); 56 + 57 + Deno.test("generateClientMetadata - removes trailing slash from baseUrl", () => { 58 + const config: ATProtoOAuthConfig = { 59 + baseUrl: "https://myapp.example.com/", 60 + appName: "Test App", 61 + cookieSecret: "a".repeat(32), 62 + storage: new MemoryStorage(), 63 + }; 64 + 65 + const metadata = generateClientMetadata(config); 66 + 67 + assertEquals(metadata.client_uri, "https://myapp.example.com"); 68 + assertEquals( 69 + metadata.client_id, 70 + "https://myapp.example.com/oauth-client-metadata.json", 71 + ); 72 + assertEquals(metadata.redirect_uris, [ 73 + "https://myapp.example.com/oauth/callback", 74 + ]); 75 + });
+37
src/client-metadata.ts
··· 1 + /** 2 + * Generate ATProto OAuth client metadata 3 + */ 4 + 5 + import type { ATProtoOAuthConfig, ClientMetadata } from "./types.ts"; 6 + 7 + /** 8 + * Generate ATProto OAuth client metadata for the /.well-known/oauth-client endpoint 9 + */ 10 + export function generateClientMetadata( 11 + config: ATProtoOAuthConfig, 12 + ): ClientMetadata { 13 + const baseUrl = config.baseUrl.replace(/\/$/, ""); 14 + 15 + const metadata: ClientMetadata = { 16 + client_name: config.appName, 17 + client_id: `${baseUrl}/oauth-client-metadata.json`, 18 + client_uri: baseUrl, 19 + redirect_uris: [`${baseUrl}/oauth/callback`], 20 + scope: config.scope || "atproto transition:generic", 21 + grant_types: ["authorization_code", "refresh_token"], 22 + response_types: ["code"], 23 + application_type: "web", 24 + token_endpoint_auth_method: "none", 25 + dpop_bound_access_tokens: true, 26 + }; 27 + 28 + if (config.logoUri) { 29 + metadata.logo_uri = config.logoUri; 30 + } 31 + 32 + if (config.policyUri) { 33 + metadata.policy_uri = config.policyUri; 34 + } 35 + 36 + return metadata; 37 + }
+201
src/oauth.test.ts
··· 1 + import { assertEquals, assertThrows } from "@std/assert"; 2 + import { createATProtoOAuth } from "./oauth.ts"; 3 + import { MemoryStorage } from "@tijs/atproto-storage"; 4 + 5 + Deno.test("createATProtoOAuth - throws on missing baseUrl", () => { 6 + assertThrows( 7 + () => { 8 + createATProtoOAuth({ 9 + baseUrl: "", 10 + appName: "Test App", 11 + cookieSecret: "a".repeat(32), 12 + storage: new MemoryStorage(), 13 + }); 14 + }, 15 + Error, 16 + "baseUrl is required", 17 + ); 18 + }); 19 + 20 + Deno.test("createATProtoOAuth - throws on missing appName", () => { 21 + assertThrows( 22 + () => { 23 + createATProtoOAuth({ 24 + baseUrl: "https://myapp.example.com", 25 + appName: "", 26 + cookieSecret: "a".repeat(32), 27 + storage: new MemoryStorage(), 28 + }); 29 + }, 30 + Error, 31 + "appName is required", 32 + ); 33 + }); 34 + 35 + Deno.test("createATProtoOAuth - throws on missing cookieSecret", () => { 36 + assertThrows( 37 + () => { 38 + createATProtoOAuth({ 39 + baseUrl: "https://myapp.example.com", 40 + appName: "Test App", 41 + cookieSecret: "", 42 + storage: new MemoryStorage(), 43 + }); 44 + }, 45 + Error, 46 + "cookieSecret is required", 47 + ); 48 + }); 49 + 50 + Deno.test("createATProtoOAuth - throws on short cookieSecret", () => { 51 + assertThrows( 52 + () => { 53 + createATProtoOAuth({ 54 + baseUrl: "https://myapp.example.com", 55 + appName: "Test App", 56 + cookieSecret: "short", 57 + storage: new MemoryStorage(), 58 + }); 59 + }, 60 + Error, 61 + "cookieSecret must be at least 32 characters", 62 + ); 63 + }); 64 + 65 + Deno.test("createATProtoOAuth - throws on missing storage", () => { 66 + assertThrows( 67 + () => { 68 + createATProtoOAuth({ 69 + baseUrl: "https://myapp.example.com", 70 + appName: "Test App", 71 + cookieSecret: "a".repeat(32), 72 + storage: undefined as unknown as MemoryStorage, 73 + }); 74 + }, 75 + Error, 76 + "storage is required", 77 + ); 78 + }); 79 + 80 + Deno.test("createATProtoOAuth - returns instance with all methods", () => { 81 + const oauth = createATProtoOAuth({ 82 + baseUrl: "https://myapp.example.com", 83 + appName: "Test App", 84 + cookieSecret: "a".repeat(32), 85 + storage: new MemoryStorage(), 86 + }); 87 + 88 + // Check all methods exist 89 + assertEquals(typeof oauth.handleLogin, "function"); 90 + assertEquals(typeof oauth.handleCallback, "function"); 91 + assertEquals(typeof oauth.handleClientMetadata, "function"); 92 + assertEquals(typeof oauth.handleLogout, "function"); 93 + assertEquals(typeof oauth.getSessionFromRequest, "function"); 94 + assertEquals(typeof oauth.getClientMetadata, "function"); 95 + assertEquals(typeof oauth.sessions.getOAuthSession, "function"); 96 + assertEquals(typeof oauth.sessions.saveOAuthSession, "function"); 97 + assertEquals(typeof oauth.sessions.deleteOAuthSession, "function"); 98 + }); 99 + 100 + Deno.test("createATProtoOAuth - handleClientMetadata returns JSON response", () => { 101 + const oauth = createATProtoOAuth({ 102 + baseUrl: "https://myapp.example.com", 103 + appName: "Test App", 104 + cookieSecret: "a".repeat(32), 105 + storage: new MemoryStorage(), 106 + }); 107 + 108 + const response = oauth.handleClientMetadata(); 109 + 110 + assertEquals(response.status, 200); 111 + assertEquals(response.headers.get("Content-Type"), "application/json"); 112 + }); 113 + 114 + Deno.test("createATProtoOAuth - getClientMetadata returns correct metadata", () => { 115 + const oauth = createATProtoOAuth({ 116 + baseUrl: "https://myapp.example.com", 117 + appName: "Test App", 118 + cookieSecret: "a".repeat(32), 119 + storage: new MemoryStorage(), 120 + logoUri: "https://myapp.example.com/logo.png", 121 + }); 122 + 123 + const metadata = oauth.getClientMetadata(); 124 + 125 + assertEquals(metadata.client_name, "Test App"); 126 + assertEquals( 127 + metadata.client_id, 128 + "https://myapp.example.com/oauth-client-metadata.json", 129 + ); 130 + assertEquals(metadata.logo_uri, "https://myapp.example.com/logo.png"); 131 + }); 132 + 133 + Deno.test("createATProtoOAuth - handleLogin returns 400 on missing handle", async () => { 134 + const oauth = createATProtoOAuth({ 135 + baseUrl: "https://myapp.example.com", 136 + appName: "Test App", 137 + cookieSecret: "a".repeat(32), 138 + storage: new MemoryStorage(), 139 + }); 140 + 141 + const request = new Request("https://myapp.example.com/login"); 142 + const response = await oauth.handleLogin(request); 143 + 144 + assertEquals(response.status, 400); 145 + assertEquals(await response.text(), "Invalid handle"); 146 + }); 147 + 148 + Deno.test("createATProtoOAuth - handleLogin returns 400 on invalid handle format", async () => { 149 + const oauth = createATProtoOAuth({ 150 + baseUrl: "https://myapp.example.com", 151 + appName: "Test App", 152 + cookieSecret: "a".repeat(32), 153 + storage: new MemoryStorage(), 154 + }); 155 + 156 + const request = new Request( 157 + "https://myapp.example.com/login?handle=invalid@@@handle", 158 + ); 159 + const response = await oauth.handleLogin(request); 160 + 161 + assertEquals(response.status, 400); 162 + assertEquals(await response.text(), "Invalid handle format"); 163 + }); 164 + 165 + Deno.test("createATProtoOAuth - getSessionFromRequest returns error on no cookie", async () => { 166 + const oauth = createATProtoOAuth({ 167 + baseUrl: "https://myapp.example.com", 168 + appName: "Test App", 169 + cookieSecret: "a".repeat(32), 170 + storage: new MemoryStorage(), 171 + }); 172 + 173 + const request = new Request("https://myapp.example.com/api/test"); 174 + const result = await oauth.getSessionFromRequest(request); 175 + 176 + assertEquals(result.session, null); 177 + assertEquals(result.error?.type, "NO_COOKIE"); 178 + }); 179 + 180 + Deno.test("createATProtoOAuth - handleLogout clears session", async () => { 181 + const oauth = createATProtoOAuth({ 182 + baseUrl: "https://myapp.example.com", 183 + appName: "Test App", 184 + cookieSecret: "a".repeat(32), 185 + storage: new MemoryStorage(), 186 + }); 187 + 188 + const request = new Request("https://myapp.example.com/api/auth/logout", { 189 + method: "POST", 190 + }); 191 + const response = await oauth.handleLogout(request); 192 + 193 + assertEquals(response.status, 200); 194 + 195 + const body = await response.json(); 196 + assertEquals(body.success, true); 197 + 198 + // Should have Set-Cookie header to clear cookie 199 + const setCookie = response.headers.get("Set-Cookie"); 200 + assertEquals(setCookie?.includes("Max-Age=0"), true); 201 + });
+190
src/oauth.ts
··· 1 + /** 2 + * Main factory function for creating ATProto OAuth integration 3 + * Framework-agnostic - works with standard Request/Response APIs 4 + */ 5 + 6 + import { OAuthClient } from "@tijs/oauth-client-deno"; 7 + import { SessionManager } from "@tijs/atproto-sessions"; 8 + 9 + import type { 10 + ATProtoOAuthConfig, 11 + ATProtoOAuthInstance, 12 + Logger, 13 + } from "./types.ts"; 14 + import { noopLogger } from "./types.ts"; 15 + import { generateClientMetadata } from "./client-metadata.ts"; 16 + import { OAuthSessions } from "./sessions.ts"; 17 + import { createRouteHandlers } from "./routes.ts"; 18 + 19 + /** Default session TTL: 7 days in seconds */ 20 + const DEFAULT_SESSION_TTL = 60 * 60 * 24 * 7; 21 + 22 + /** Default mobile callback scheme */ 23 + const DEFAULT_MOBILE_SCHEME = "app://auth-callback"; 24 + 25 + /** 26 + * Create a complete ATProto OAuth integration for any framework. 27 + * 28 + * This function sets up everything needed for ATProto/Bluesky OAuth authentication, 29 + * with route handlers that work with standard Web Request/Response APIs. 30 + * 31 + * @param config - Configuration object for OAuth integration 32 + * @returns ATProto OAuth instance with route handlers and session management 33 + * 34 + * @example Basic setup 35 + * ```typescript 36 + * import { createATProtoOAuth } from "@tijs/atproto-oauth"; 37 + * import { SQLiteStorage, valTownAdapter } from "@tijs/atproto-storage"; 38 + * 39 + * const oauth = createATProtoOAuth({ 40 + * baseUrl: "https://myapp.example.com", 41 + * appName: "My App", 42 + * cookieSecret: Deno.env.get("COOKIE_SECRET")!, 43 + * storage: new SQLiteStorage(valTownAdapter(sqlite)), 44 + * sessionTtl: 60 * 60 * 24 * 14, // 14 days 45 + * }); 46 + * 47 + * // Use route handlers in your framework 48 + * // Hono: 49 + * app.get("/login", (c) => oauth.handleLogin(c.req.raw)); 50 + * app.get("/oauth/callback", (c) => oauth.handleCallback(c.req.raw)); 51 + * app.get("/oauth-client-metadata.json", () => oauth.handleClientMetadata()); 52 + * app.post("/api/auth/logout", (c) => oauth.handleLogout(c.req.raw)); 53 + * 54 + * // Oak: 55 + * router.get("/login", (ctx) => ctx.respond = false; return oauth.handleLogin(ctx.request.originalRequest)); 56 + * 57 + * // Fresh (Deno): 58 + * export const handler = async (req) => oauth.handleLogin(req); 59 + * ``` 60 + * 61 + * @example Getting authenticated session in routes 62 + * ```typescript 63 + * app.get("/api/profile", async (c) => { 64 + * const { session, setCookieHeader, error } = await oauth.getSessionFromRequest(c.req.raw); 65 + * 66 + * if (!session) { 67 + * return c.json({ error: error?.message || "Not authenticated" }, 401); 68 + * } 69 + * 70 + * // Make authenticated API call 71 + * const response = await session.makeRequest( 72 + * "GET", 73 + * `${session.pdsUrl}/xrpc/app.bsky.actor.getProfile?actor=${session.did}` 74 + * ); 75 + * 76 + * const profile = await response.json(); 77 + * 78 + * const res = c.json(profile); 79 + * if (setCookieHeader) { 80 + * res.headers.set("Set-Cookie", setCookieHeader); 81 + * } 82 + * return res; 83 + * }); 84 + * ``` 85 + */ 86 + export function createATProtoOAuth( 87 + config: ATProtoOAuthConfig, 88 + ): ATProtoOAuthInstance { 89 + // Validate required config 90 + if (!config.baseUrl) { 91 + throw new Error("baseUrl is required"); 92 + } 93 + if (!config.appName) { 94 + throw new Error("appName is required"); 95 + } 96 + if (!config.cookieSecret) { 97 + throw new Error("cookieSecret is required"); 98 + } 99 + if (config.cookieSecret.length < 32) { 100 + throw new Error( 101 + "cookieSecret must be at least 32 characters for secure encryption", 102 + ); 103 + } 104 + if (!config.storage) { 105 + throw new Error("storage is required"); 106 + } 107 + 108 + // Normalize baseUrl 109 + const baseUrl = config.baseUrl.replace(/\/$/, ""); 110 + const sessionTtl = config.sessionTtl ?? DEFAULT_SESSION_TTL; 111 + const mobileScheme = config.mobileScheme ?? DEFAULT_MOBILE_SCHEME; 112 + const logger: Logger = config.logger ?? noopLogger; 113 + 114 + // Create OAuth client 115 + const oauthClient = new OAuthClient({ 116 + clientId: `${baseUrl}/oauth-client-metadata.json`, 117 + redirectUri: `${baseUrl}/oauth/callback`, 118 + storage: config.storage, 119 + logger: { 120 + debug: (msg: string, ...args: unknown[]) => { 121 + logger.log(`[DEBUG] ${msg}`, ...args); 122 + }, 123 + info: (msg: string, ...args: unknown[]) => { 124 + logger.log(`[INFO] ${msg}`, ...args); 125 + }, 126 + warn: (msg: string, ...args: unknown[]) => { 127 + logger.warn(msg, ...args); 128 + }, 129 + error: (msg: string, ...args: unknown[]) => { 130 + logger.error(msg, ...args); 131 + }, 132 + }, 133 + }); 134 + 135 + // Create session manager for cookie handling 136 + const sessionManager = new SessionManager({ 137 + cookieSecret: config.cookieSecret, 138 + cookieName: "sid", 139 + sessionTtl, 140 + logger, 141 + }); 142 + 143 + // Create OAuth sessions manager 144 + const oauthSessions = new OAuthSessions({ 145 + oauthClient, 146 + storage: config.storage, 147 + sessionTtl, 148 + logger, 149 + }); 150 + 151 + // Create route handlers 152 + const handlers = createRouteHandlers({ 153 + baseUrl, 154 + oauthClient, 155 + sessionManager, 156 + oauthSessions, 157 + storage: config.storage, 158 + sessionTtl, 159 + mobileScheme, 160 + logger, 161 + }); 162 + 163 + // Generate client metadata 164 + const clientMetadata = generateClientMetadata({ 165 + ...config, 166 + baseUrl, 167 + }); 168 + 169 + /** 170 + * Handle /oauth-client-metadata.json route 171 + */ 172 + function handleClientMetadata(): Response { 173 + return new Response(JSON.stringify(clientMetadata), { 174 + status: 200, 175 + headers: { 176 + "Content-Type": "application/json", 177 + }, 178 + }); 179 + } 180 + 181 + return { 182 + handleLogin: handlers.handleLogin, 183 + handleCallback: handlers.handleCallback, 184 + handleClientMetadata, 185 + handleLogout: handlers.handleLogout, 186 + getSessionFromRequest: handlers.getSessionFromRequest, 187 + getClientMetadata: () => clientMetadata, 188 + sessions: handlers.sessions, 189 + }; 190 + }
+338
src/routes.ts
··· 1 + /** 2 + * OAuth route handlers for ATProto authentication 3 + * Framework-agnostic - works with standard Request/Response APIs 4 + */ 5 + 6 + import type { OAuthStorage } from "@tijs/atproto-storage"; 7 + import type { SessionManager } from "@tijs/atproto-sessions"; 8 + import { isValidHandle } from "@atproto/syntax"; 9 + 10 + import type { 11 + Logger, 12 + OAuthClientInterface, 13 + OAuthSessionFromRequestResult, 14 + OAuthSessionsInterface, 15 + OAuthState, 16 + } from "./types.ts"; 17 + import type { OAuthSessions } from "./sessions.ts"; 18 + 19 + /** 20 + * Configuration for route handlers 21 + */ 22 + export interface RouteHandlersConfig { 23 + baseUrl: string; 24 + oauthClient: OAuthClientInterface; 25 + sessionManager: SessionManager; 26 + oauthSessions: OAuthSessions; 27 + storage: OAuthStorage; 28 + sessionTtl: number; 29 + mobileScheme: string; 30 + logger: Logger; 31 + } 32 + 33 + /** 34 + * Create route handlers for OAuth authentication 35 + */ 36 + export function createRouteHandlers(config: RouteHandlersConfig): { 37 + handleLogin: (request: Request) => Promise<Response>; 38 + handleCallback: (request: Request) => Promise<Response>; 39 + handleLogout: (request: Request) => Promise<Response>; 40 + getSessionFromRequest: ( 41 + request: Request, 42 + ) => Promise<OAuthSessionFromRequestResult>; 43 + sessions: OAuthSessionsInterface; 44 + } { 45 + const { 46 + oauthClient, 47 + sessionManager, 48 + oauthSessions, 49 + storage, 50 + sessionTtl, 51 + mobileScheme, 52 + logger, 53 + } = config; 54 + 55 + /** 56 + * Handle /login route - start OAuth flow 57 + */ 58 + async function handleLogin(request: Request): Promise<Response> { 59 + const url = new URL(request.url); 60 + const handle = url.searchParams.get("handle"); 61 + const redirect = url.searchParams.get("redirect"); 62 + 63 + if (!handle || typeof handle !== "string") { 64 + return new Response("Invalid handle", { status: 400 }); 65 + } 66 + 67 + if (!isValidHandle(handle)) { 68 + return new Response("Invalid handle format", { status: 400 }); 69 + } 70 + 71 + try { 72 + const state: OAuthState = { 73 + handle, 74 + timestamp: Date.now(), 75 + }; 76 + 77 + // Store redirect path for post-OAuth redirect (validate it's a relative path) 78 + if (redirect) { 79 + // Security: Only allow relative paths starting with / 80 + if (redirect.startsWith("/") && !redirect.startsWith("//")) { 81 + state.redirectPath = redirect; 82 + } else { 83 + logger.warn(`Invalid redirect path ignored: ${redirect}`); 84 + } 85 + } 86 + 87 + const authUrl = await oauthClient.authorize(handle, { 88 + state: JSON.stringify(state), 89 + }); 90 + 91 + return new Response(null, { 92 + status: 302, 93 + headers: { Location: authUrl.toString() }, 94 + }); 95 + } catch (err) { 96 + logger.error("OAuth authorize failed:", err); 97 + return new Response( 98 + err instanceof Error ? err.message : "Couldn't initiate login", 99 + { status: 400 }, 100 + ); 101 + } 102 + } 103 + 104 + /** 105 + * Handle /oauth/callback route - complete OAuth flow 106 + */ 107 + async function handleCallback(request: Request): Promise<Response> { 108 + try { 109 + const url = new URL(request.url); 110 + const params = url.searchParams; 111 + 112 + const code = params.get("code"); 113 + const stateParam = params.get("state"); 114 + 115 + if (!code || !stateParam) { 116 + return new Response("Missing code or state parameters", { 117 + status: 400, 118 + }); 119 + } 120 + 121 + // Parse state 122 + let state: OAuthState; 123 + try { 124 + state = JSON.parse(stateParam); 125 + } catch { 126 + return new Response("Invalid state parameter", { status: 400 }); 127 + } 128 + 129 + // Complete OAuth callback 130 + const callbackResult = await oauthClient.callback(params); 131 + const { session: oauthSession } = callbackResult; 132 + const did = oauthSession.did; 133 + 134 + // Store OAuth session data with TTL 135 + await storage.set(`session:${did}`, oauthSession.toJSON(), { 136 + ttl: sessionTtl, 137 + }); 138 + 139 + // Create session cookie 140 + const now = Date.now(); 141 + const setCookieHeader = await sessionManager.createSession({ 142 + did, 143 + createdAt: now, 144 + lastAccessed: now, 145 + }); 146 + 147 + // Handle mobile callback 148 + if (state.mobile) { 149 + const sealedToken = await sessionManager.sealToken({ did }); 150 + 151 + const mobileCallbackUrl = new URL(mobileScheme); 152 + mobileCallbackUrl.searchParams.set("session_token", sealedToken); 153 + mobileCallbackUrl.searchParams.set("did", did); 154 + mobileCallbackUrl.searchParams.set("handle", state.handle); 155 + 156 + if (oauthSession.accessToken) { 157 + mobileCallbackUrl.searchParams.set( 158 + "access_token", 159 + oauthSession.accessToken, 160 + ); 161 + } 162 + if (oauthSession.refreshToken) { 163 + mobileCallbackUrl.searchParams.set( 164 + "refresh_token", 165 + oauthSession.refreshToken, 166 + ); 167 + } 168 + 169 + return new Response(null, { 170 + status: 302, 171 + headers: { 172 + Location: mobileCallbackUrl.toString(), 173 + "Set-Cookie": setCookieHeader, 174 + }, 175 + }); 176 + } 177 + 178 + // Web callback - redirect to stored path or home 179 + const redirectPath = state.redirectPath || "/"; 180 + 181 + return new Response(null, { 182 + status: 302, 183 + headers: { 184 + Location: redirectPath, 185 + "Set-Cookie": setCookieHeader, 186 + }, 187 + }); 188 + } catch (error) { 189 + const message = error instanceof Error ? error.message : String(error); 190 + logger.error("OAuth callback failed:", error); 191 + return new Response(`OAuth callback failed: ${message}`, { status: 400 }); 192 + } 193 + } 194 + 195 + /** 196 + * Handle /api/auth/logout route 197 + */ 198 + async function handleLogout(request: Request): Promise<Response> { 199 + try { 200 + // Try to get current session to clean up OAuth data 201 + const sessionResult = await sessionManager.getSessionFromRequest(request); 202 + if (sessionResult.data?.did) { 203 + await storage.delete(`session:${sessionResult.data.did}`); 204 + } 205 + 206 + // Clear session cookie 207 + const clearCookie = sessionManager.getClearCookieHeader(); 208 + 209 + return new Response(JSON.stringify({ success: true }), { 210 + status: 200, 211 + headers: { 212 + "Content-Type": "application/json", 213 + "Set-Cookie": clearCookie, 214 + }, 215 + }); 216 + } catch (error) { 217 + logger.error("Logout failed:", error); 218 + return new Response( 219 + JSON.stringify({ success: false, error: "Logout failed" }), 220 + { 221 + status: 500, 222 + headers: { "Content-Type": "application/json" }, 223 + }, 224 + ); 225 + } 226 + } 227 + 228 + /** 229 + * Get OAuth session from request (cookie or Bearer token) 230 + */ 231 + async function getSessionFromRequest( 232 + request: Request, 233 + ): Promise<OAuthSessionFromRequestResult> { 234 + // Check for Bearer token first (mobile) 235 + const authHeader = request.headers.get("Authorization"); 236 + if (authHeader && authHeader.startsWith("Bearer ")) { 237 + const tokenResult = await sessionManager.validateBearerToken(authHeader); 238 + if (tokenResult.data?.did) { 239 + try { 240 + const oauthSession = await oauthSessions.getOAuthSession( 241 + tokenResult.data.did, 242 + ); 243 + if (oauthSession) { 244 + return { session: oauthSession }; 245 + } 246 + return { 247 + session: null, 248 + error: { 249 + type: "SESSION_EXPIRED", 250 + message: "OAuth session not found in storage", 251 + }, 252 + }; 253 + } catch (error) { 254 + return { 255 + session: null, 256 + error: { 257 + type: "OAUTH_ERROR", 258 + message: error instanceof Error 259 + ? error.message 260 + : "OAuth session restore failed", 261 + details: error, 262 + }, 263 + }; 264 + } 265 + } 266 + return { 267 + session: null, 268 + error: tokenResult.error 269 + ? { 270 + type: "INVALID_COOKIE", 271 + message: tokenResult.error.message, 272 + } 273 + : { type: "INVALID_COOKIE", message: "Invalid token" }, 274 + }; 275 + } 276 + 277 + // Check for session cookie (web) 278 + const sessionResult = await sessionManager.getSessionFromRequest(request); 279 + if (!sessionResult.data?.did) { 280 + return { 281 + session: null, 282 + error: sessionResult.error 283 + ? { 284 + type: sessionResult.error.type as 285 + | "NO_COOKIE" 286 + | "INVALID_COOKIE" 287 + | "SESSION_EXPIRED" 288 + | "UNKNOWN", 289 + message: sessionResult.error.message, 290 + details: sessionResult.error.details, 291 + } 292 + : { type: "NO_COOKIE", message: "No session found" }, 293 + }; 294 + } 295 + 296 + // Get OAuth session 297 + try { 298 + const oauthSession = await oauthSessions.getOAuthSession( 299 + sessionResult.data.did, 300 + ); 301 + if (!oauthSession) { 302 + return { 303 + session: null, 304 + setCookieHeader: sessionResult.setCookieHeader, 305 + error: { 306 + type: "SESSION_EXPIRED", 307 + message: "OAuth session not found in storage", 308 + }, 309 + }; 310 + } 311 + 312 + return { 313 + session: oauthSession, 314 + setCookieHeader: sessionResult.setCookieHeader, 315 + }; 316 + } catch (error) { 317 + return { 318 + session: null, 319 + setCookieHeader: sessionResult.setCookieHeader, 320 + error: { 321 + type: "OAUTH_ERROR", 322 + message: error instanceof Error 323 + ? error.message 324 + : "OAuth session restore failed", 325 + details: error, 326 + }, 327 + }; 328 + } 329 + } 330 + 331 + return { 332 + handleLogin, 333 + handleCallback, 334 + handleLogout, 335 + getSessionFromRequest, 336 + sessions: oauthSessions, 337 + }; 338 + }
+116
src/sessions.ts
··· 1 + /** 2 + * OAuth session management 3 + * Framework-agnostic session storage and retrieval 4 + */ 5 + 6 + import type { OAuthStorage } from "@tijs/atproto-storage"; 7 + import type { 8 + Logger, 9 + OAuthClientInterface, 10 + OAuthSessionsInterface, 11 + SessionInterface, 12 + } from "./types.ts"; 13 + import { noopLogger } from "./types.ts"; 14 + 15 + /** 16 + * Configuration for OAuthSessions 17 + */ 18 + export interface OAuthSessionsConfig { 19 + /** OAuth client for session restoration */ 20 + oauthClient: OAuthClientInterface; 21 + 22 + /** Storage for OAuth session data */ 23 + storage: OAuthStorage; 24 + 25 + /** Session TTL in seconds */ 26 + sessionTtl: number; 27 + 28 + /** Optional logger */ 29 + logger?: Logger; 30 + } 31 + 32 + /** 33 + * OAuth session manager - handles storing and restoring OAuth sessions 34 + */ 35 + export class OAuthSessions implements OAuthSessionsInterface { 36 + private readonly oauthClient: OAuthClientInterface; 37 + private readonly storage: OAuthStorage; 38 + private readonly sessionTtl: number; 39 + private readonly logger: Logger; 40 + 41 + constructor(config: OAuthSessionsConfig) { 42 + this.oauthClient = config.oauthClient; 43 + this.storage = config.storage; 44 + this.sessionTtl = config.sessionTtl; 45 + this.logger = config.logger ?? noopLogger; 46 + } 47 + 48 + /** 49 + * Get OAuth session for a DID with automatic token refresh 50 + */ 51 + async getOAuthSession(did: string): Promise<SessionInterface | null> { 52 + this.logger.log(`Restoring OAuth session for DID: ${did}`); 53 + 54 + try { 55 + // The OAuth client's restore() method handles automatic token refresh 56 + const session = await this.oauthClient.restore(did); 57 + 58 + if (session) { 59 + this.logger.log(`OAuth session restored successfully for DID: ${did}`); 60 + 61 + // Log token expiration information if available 62 + if (session.timeUntilExpiry !== undefined) { 63 + const timeUntilExpiryMinutes = Math.round( 64 + session.timeUntilExpiry / 1000 / 60, 65 + ); 66 + const wasLikelyRefreshed = session.timeUntilExpiry > (60 * 60 * 1000); // More than 1 hour 67 + const now = Date.now(); 68 + const expiresAt = now + session.timeUntilExpiry; 69 + 70 + this.logger.log(`Token status for DID ${did}:`, { 71 + expiresAt: new Date(expiresAt).toISOString(), 72 + currentTime: new Date(now).toISOString(), 73 + timeUntilExpiryMinutes, 74 + wasLikelyRefreshed, 75 + hasRefreshToken: !!session.refreshToken, 76 + }); 77 + } 78 + } else { 79 + this.logger.log(`OAuth session not found for DID: ${did}`); 80 + } 81 + 82 + return session; 83 + } catch (error) { 84 + this.logger.error(`Failed to restore OAuth session for DID ${did}:`, { 85 + error: error instanceof Error ? error.message : String(error), 86 + errorName: error instanceof Error ? error.constructor.name : "Unknown", 87 + stack: error instanceof Error ? error.stack : undefined, 88 + }); 89 + throw error; // Re-throw to let caller handle specific error types 90 + } 91 + } 92 + 93 + /** 94 + * Save OAuth session to storage 95 + */ 96 + async saveOAuthSession(session: SessionInterface): Promise<void> { 97 + this.logger.log(`Saving OAuth session for DID: ${session.did}`); 98 + 99 + await this.storage.set(`session:${session.did}`, session.toJSON(), { 100 + ttl: this.sessionTtl, 101 + }); 102 + 103 + this.logger.log(`OAuth session saved for DID: ${session.did}`); 104 + } 105 + 106 + /** 107 + * Delete OAuth session from storage 108 + */ 109 + async deleteOAuthSession(did: string): Promise<void> { 110 + this.logger.log(`Deleting OAuth session for DID: ${did}`); 111 + 112 + await this.storage.delete(`session:${did}`); 113 + 114 + this.logger.log(`OAuth session deleted for DID: ${did}`); 115 + } 116 + }
+317
src/types.ts
··· 1 + /** 2 + * Types for AT Protocol OAuth integration 3 + * Framework-agnostic - works with standard Request/Response APIs 4 + */ 5 + 6 + import type { OAuthStorage } from "@tijs/atproto-storage"; 7 + 8 + /** 9 + * Logger interface for custom logging implementations 10 + */ 11 + export interface Logger { 12 + log(...args: unknown[]): void; 13 + warn(...args: unknown[]): void; 14 + error(...args: unknown[]): void; 15 + } 16 + 17 + /** 18 + * No-op logger for production use 19 + */ 20 + export const noopLogger: Logger = { 21 + log: () => {}, 22 + warn: () => {}, 23 + error: () => {}, 24 + }; 25 + 26 + /** 27 + * Generic OAuth session interface 28 + * 29 + * Compatible with @tijs/oauth-client-deno Session class and similar implementations. 30 + * For AT Protocol applications, makeRequest() provides automatic DPoP authentication. 31 + */ 32 + export interface SessionInterface { 33 + /** User's DID */ 34 + did: string; 35 + 36 + /** Access token for API calls */ 37 + accessToken: string; 38 + 39 + /** Refresh token (optional) */ 40 + refreshToken?: string; 41 + 42 + /** Handle/username (optional) */ 43 + handle?: string; 44 + 45 + /** User's PDS URL */ 46 + pdsUrl: string; 47 + 48 + /** Time until token expires in milliseconds (optional) */ 49 + timeUntilExpiry?: number; 50 + 51 + /** 52 + * Make authenticated request with automatic DPoP handling. 53 + */ 54 + makeRequest( 55 + method: string, 56 + url: string, 57 + options?: RequestInit, 58 + ): Promise<Response>; 59 + 60 + /** 61 + * Refresh tokens (optional) 62 + */ 63 + refresh?(): Promise<SessionInterface>; 64 + 65 + /** 66 + * Serialize session data for storage 67 + */ 68 + toJSON(): unknown; 69 + } 70 + 71 + /** 72 + * Generic OAuth client interface - bring your own client! 73 + * Compatible with @tijs/oauth-client-deno v1.0.0+ 74 + */ 75 + export interface OAuthClientInterface { 76 + /** 77 + * Start OAuth authorization flow 78 + * @returns URL object for authorization redirect 79 + */ 80 + authorize(handle: string, options?: { state?: string }): Promise<URL>; 81 + 82 + /** 83 + * Handle OAuth callback and exchange code for tokens 84 + * @param params URLSearchParams from OAuth callback 85 + */ 86 + callback(params: URLSearchParams): Promise<{ 87 + session: SessionInterface; 88 + state?: string | null; 89 + }>; 90 + 91 + /** 92 + * Restore a session from storage by session ID. 93 + * The OAuth client should handle automatic token refresh during restore if needed. 94 + * @param sessionId - Session identifier to restore 95 + * @returns Promise resolving to restored session, or null if not found 96 + */ 97 + restore(sessionId: string): Promise<SessionInterface | null>; 98 + } 99 + 100 + /** 101 + * Configuration options for ATProto OAuth integration. 102 + */ 103 + export interface ATProtoOAuthConfig { 104 + /** Base URL of your application (e.g. "https://myapp.example.com") */ 105 + baseUrl: string; 106 + 107 + /** Display name for OAuth consent screen */ 108 + appName: string; 109 + 110 + /** Custom URL scheme for mobile app callbacks (default: "app://auth-callback") */ 111 + mobileScheme?: string; 112 + 113 + /** URL to app logo for OAuth consent screen */ 114 + logoUri?: string; 115 + 116 + /** URL to privacy policy */ 117 + policyUri?: string; 118 + 119 + /** Cookie signing secret (required, at least 32 characters) */ 120 + cookieSecret: string; 121 + 122 + /** OAuth scope (default: "atproto transition:generic") */ 123 + scope?: string; 124 + 125 + /** 126 + * Session TTL in seconds (default: 7 days). 127 + * For AT Protocol OAuth public clients, max is 14 days per spec. 128 + */ 129 + sessionTtl?: number; 130 + 131 + /** Storage implementation for OAuth sessions */ 132 + storage: OAuthStorage; 133 + 134 + /** 135 + * Optional logger for debugging and monitoring OAuth flows. 136 + * Defaults to a no-op logger (no console output). 137 + * Pass console for standard logging. 138 + */ 139 + logger?: Logger; 140 + } 141 + 142 + /** 143 + * ATProto OAuth client metadata for /.well-known/oauth-client 144 + */ 145 + export interface ClientMetadata { 146 + client_name: string; 147 + client_id: string; 148 + client_uri: string; 149 + redirect_uris: string[]; 150 + scope: string; 151 + grant_types: string[]; 152 + response_types: string[]; 153 + application_type: string; 154 + token_endpoint_auth_method: string; 155 + dpop_bound_access_tokens: boolean; 156 + logo_uri?: string; 157 + policy_uri?: string; 158 + } 159 + 160 + /** 161 + * Session validation result. 162 + */ 163 + export interface SessionValidationResult { 164 + valid: boolean; 165 + did?: string; 166 + handle?: string; 167 + } 168 + 169 + /** 170 + * Mobile OAuth start request 171 + */ 172 + export interface MobileOAuthStartRequest { 173 + handle: string; 174 + code_challenge: string; 175 + } 176 + 177 + /** 178 + * Mobile OAuth start response 179 + */ 180 + export interface MobileOAuthStartResponse { 181 + success: boolean; 182 + authUrl?: string; 183 + error?: string; 184 + } 185 + 186 + /** 187 + * Stored OAuth session data 188 + */ 189 + export interface StoredOAuthSession { 190 + did: string; 191 + accessToken: string; 192 + refreshToken?: string; 193 + handle?: string; 194 + pdsUrl: string; 195 + expiresAt?: number; 196 + createdAt: number; 197 + updatedAt: number; 198 + } 199 + 200 + /** 201 + * Iron Session data stored in encrypted cookie 202 + */ 203 + export interface SessionData { 204 + did: string; 205 + createdAt: number; 206 + lastAccessed: number; 207 + } 208 + 209 + /** 210 + * OAuth sessions manager interface 211 + */ 212 + export interface OAuthSessionsInterface { 213 + /** 214 + * Get an OAuth session for a specific DID 215 + * @param did - User's DID 216 + * @returns OAuth session or null if not found 217 + */ 218 + getOAuthSession(did: string): Promise<SessionInterface | null>; 219 + 220 + /** 221 + * Save OAuth session to storage 222 + * @param session - Session to save 223 + */ 224 + saveOAuthSession(session: SessionInterface): Promise<void>; 225 + 226 + /** 227 + * Delete OAuth session from storage 228 + * @param did - User's DID 229 + */ 230 + deleteOAuthSession(did: string): Promise<void>; 231 + } 232 + 233 + /** 234 + * Result from getOAuthSessionFromRequest() 235 + */ 236 + export interface OAuthSessionFromRequestResult { 237 + /** The OAuth session, or null if not found/invalid */ 238 + session: SessionInterface | null; 239 + 240 + /** Set-Cookie header to refresh the session (set when session is valid) */ 241 + setCookieHeader?: string; 242 + 243 + /** Error information if session retrieval failed */ 244 + error?: { 245 + type: 246 + | "NO_COOKIE" 247 + | "INVALID_COOKIE" 248 + | "SESSION_EXPIRED" 249 + | "OAUTH_ERROR" 250 + | "UNKNOWN"; 251 + message: string; 252 + details?: unknown; 253 + }; 254 + } 255 + 256 + /** 257 + * ATProto OAuth instance returned by createATProtoOAuth(). 258 + */ 259 + export interface ATProtoOAuthInstance { 260 + /** 261 + * Handle /login route - start OAuth flow 262 + * @param request - HTTP request with ?handle= query param 263 + * @returns Response (redirect to OAuth provider) 264 + */ 265 + handleLogin(request: Request): Promise<Response>; 266 + 267 + /** 268 + * Handle /oauth/callback route - complete OAuth flow 269 + * @param request - HTTP request from OAuth callback 270 + * @returns Response (redirect to app) 271 + */ 272 + handleCallback(request: Request): Promise<Response>; 273 + 274 + /** 275 + * Handle /oauth-client-metadata.json route 276 + * @returns Response with client metadata JSON 277 + */ 278 + handleClientMetadata(): Response; 279 + 280 + /** 281 + * Handle /api/auth/logout route 282 + * @param request - HTTP request 283 + * @returns Response 284 + */ 285 + handleLogout(request: Request): Promise<Response>; 286 + 287 + /** 288 + * Get OAuth session from request (cookie or Bearer token) 289 + * @param request - HTTP request 290 + * @returns Session result with optional Set-Cookie header 291 + */ 292 + getSessionFromRequest( 293 + request: Request, 294 + ): Promise<OAuthSessionFromRequestResult>; 295 + 296 + /** 297 + * Generate client metadata 298 + */ 299 + getClientMetadata(): ClientMetadata; 300 + 301 + /** Direct access to sessions interface for advanced usage */ 302 + sessions: OAuthSessionsInterface; 303 + } 304 + 305 + /** 306 + * OAuth state stored during authorization flow 307 + */ 308 + export interface OAuthState { 309 + handle: string; 310 + timestamp: number; 311 + mobile?: boolean; 312 + codeChallenge?: string; 313 + redirectPath?: string; 314 + } 315 + 316 + // Re-export OAuthStorage from atproto-storage for convenience 317 + export type { OAuthStorage };