A website for the ATmosphereConf

Once more with README merges

authored by daffl.xyz and committed by bmann.ca b712e2ba f670c9a8

Changed files
+124 -61
src
+48 -17
README.md
··· 11 11 ## Conference Profile 12 12 For starters, we're going to have a custom conference profile. Attendees and speakers (and anyone else!) can login and create an extended profile. Inspired by [Discover Toronto](https://discover.toronto.inc/), we had an [initial discussion in the forum](https://discourse.atprotocol.community/t/conference-profiles/186) and are going to work on fleshing this out here with detailed issues. 13 13 14 - # Astro Starter Kit: Minimal 14 + # Astro ATProto OAuth Starter 15 + 16 + A minimal [Astro](https://astro.build) starter template demonstrating OAuth authentication with AT Protocol (ATProto), the decentralized social networking protocol used by Bluesky and other services. 17 + 18 + This starter includes: 19 + - Complete OAuth authentication flow using `@atproto/oauth-client-node` 20 + - Cookie-based session management 21 + - Profile display after authentication 22 + - Login/logout endpoints 23 + - Tailwind CSS and DaisyUI styling 24 + 25 + ## ๐Ÿš€ Getting Started 26 + 27 + 1. **Install dependencies:** 28 + ```sh 29 + npm install 30 + ``` 15 31 16 - ```sh 17 - npm create astro@latest -- --template minimal 18 - ``` 32 + 2. **Configure environment variables:** 33 + ```sh 34 + cp .env.template .env 35 + ``` 36 + Edit `.env` if you need to change the port (default: 4321) or set a public URL. 19 37 20 - > ๐Ÿง‘โ€๐Ÿš€ **Seasoned astronaut?** Delete this file. Have fun! 38 + 3. **Start the development server:** 39 + ```sh 40 + npm run dev 41 + ``` 42 + The app will be available at `http://localhost:4321` 21 43 22 - ## ๐Ÿš€ Project Structure 44 + 4. **Try logging in:** 45 + Enter your AT Protocol handle (e.g., `alice.bsky.social`) to authenticate. 23 46 24 - Inside of your Astro project, you'll see the following folders and files: 47 + ## ๐Ÿ“ Project Structure 25 48 26 49 ```text 27 50 / 28 51 โ”œโ”€โ”€ public/ 29 52 โ”œโ”€โ”€ src/ 30 - โ”‚ โ””โ”€โ”€ pages/ 31 - โ”‚ โ””โ”€โ”€ index.astro 53 + โ”‚ โ”œโ”€โ”€ lib/ 54 + โ”‚ โ”‚ โ”œโ”€โ”€ context.ts # OAuth client singleton 55 + โ”‚ โ”‚ โ”œโ”€โ”€ oauth.ts # OAuth client configuration 56 + โ”‚ โ”‚ โ”œโ”€โ”€ session.ts # Session management 57 + โ”‚ โ”‚ โ””โ”€โ”€ storage.ts # Cookie-based stores 58 + โ”‚ โ”œโ”€โ”€ pages/ 59 + โ”‚ โ”‚ โ”œโ”€โ”€ api/ 60 + โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ login.ts # Login endpoint 61 + โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ logout.ts # Logout endpoint 62 + โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ oauth/ 63 + โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ callback.ts # OAuth callback handler 64 + โ”‚ โ”‚ โ””โ”€โ”€ index.astro # Main page with login UI 65 + โ”‚ โ””โ”€โ”€ styles.css 32 66 โ””โ”€โ”€ package.json 33 67 ``` 34 - 35 - Astro looks for `.astro` or `.md` files in the `src/pages/` directory. Each page is exposed as a route based on its file name. 36 - 37 - There's nothing special about `src/components/`, but that's where we like to put any Astro/React/Vue/Svelte/Preact components. 38 - 39 - Any static assets, like images, can be placed in the `public/` directory. 40 68 41 69 ## ๐Ÿงž Commands 42 70 ··· 51 79 | `npm run astro ...` | Run CLI commands like `astro add`, `astro check` | 52 80 | `npm run astro -- --help` | Get help using the Astro CLI | 53 81 54 - ## ๐Ÿ‘€ Want to learn more? 82 + ## ๐Ÿ“š Learn More 55 83 56 - Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat). 84 + - [Astro Documentation](https://docs.astro.build) 85 + - [AT Protocol Documentation](https://atproto.com) 86 + - [@atproto/oauth-client-node](https://github.com/bluesky-social/atproto/tree/main/packages/oauth/oauth-client-node) 87 + - [Bluesky](https://bsky.app)
+4 -14
src/lib/context.ts
··· 1 - import { NodeOAuthClient } from '@atproto/oauth-client-node' 1 + import type { AstroCookies } from 'astro' 2 2 import { createOAuthClient } from './oauth' 3 3 4 - export type AppContext = { 5 - oauthClient: NodeOAuthClient 6 - } 7 - 8 - let _ctx: AppContext | null = null 9 - 10 - export async function getAppContext(): Promise<AppContext> { 11 - if (_ctx) return _ctx 12 - 13 - const oauthClient = await createOAuthClient() 14 - 15 - _ctx = { oauthClient } 16 - return _ctx 4 + // Create a request-scoped OAuth client with cookie-based storage 5 + export function getOAuthClient(cookies: AstroCookies) { 6 + return createOAuthClient(cookies) 17 7 }
+5 -4
src/lib/oauth.ts
··· 1 + import type { AstroCookies } from 'astro' 1 2 import { 2 3 atprotoLoopbackClientMetadata, 3 4 NodeOAuthClient, 4 5 } from "@atproto/oauth-client-node"; 5 6 import { env } from "./env"; 6 - import { SessionStore, StateStore } from "./storage"; 7 + import { CookieSessionStore, CookieStateStore } from "./storage"; 7 8 8 - export async function createOAuthClient() { 9 + export function createOAuthClient(cookies: AstroCookies) { 9 10 const clientMetadata = atprotoLoopbackClientMetadata( 10 11 `http://localhost?${new URLSearchParams([ 11 12 ["redirect_uri", `http://127.0.0.1:${env.PORT}/api/oauth/callback`], ··· 15 16 16 17 return new NodeOAuthClient({ 17 18 clientMetadata, 18 - stateStore: new StateStore(), 19 - sessionStore: new SessionStore(), 19 + stateStore: new CookieStateStore(cookies), 20 + sessionStore: new CookieSessionStore(cookies), 20 21 }); 21 22 }
+53 -12
src/lib/storage.ts
··· 1 + import type { AstroCookies } from 'astro' 1 2 import type { 2 3 NodeSavedSession, 3 4 NodeSavedSessionStore, ··· 5 6 NodeSavedStateStore, 6 7 } from '@atproto/oauth-client-node' 7 8 8 - // In-memory storage for OAuth state and sessions 9 - // For production, you'd want to use a proper database or distributed cache 9 + // Cookie-based storage for OAuth state and sessions 10 + // All data is serialized into cookies for stateless operation 10 11 11 - export class StateStore implements NodeSavedStateStore { 12 - private store = new Map<string, NodeSavedState>() 12 + export class CookieStateStore implements NodeSavedStateStore { 13 + constructor(private cookies: AstroCookies) {} 13 14 14 15 async get(key: string): Promise<NodeSavedState | undefined> { 15 - return this.store.get(key) 16 + const cookieName = `oauth_state_${key}` 17 + const cookie = this.cookies.get(cookieName) 18 + if (!cookie?.value) return undefined 19 + 20 + try { 21 + const decoded = atob(cookie.value) 22 + return JSON.parse(decoded) as NodeSavedState 23 + } catch (err) { 24 + console.warn('Failed to decode OAuth state:', err) 25 + return undefined 26 + } 16 27 } 17 28 18 29 async set(key: string, val: NodeSavedState) { 19 - this.store.set(key, val) 30 + const cookieName = `oauth_state_${key}` 31 + const encoded = btoa(JSON.stringify(val)) 32 + 33 + this.cookies.set(cookieName, encoded, { 34 + httpOnly: true, 35 + secure: false, 36 + sameSite: 'lax', 37 + path: '/', 38 + maxAge: 60 * 10, // 10 minutes (OAuth flow timeout) 39 + }) 20 40 } 21 41 22 42 async del(key: string) { 23 - this.store.delete(key) 43 + const cookieName = `oauth_state_${key}` 44 + this.cookies.delete(cookieName, { path: '/' }) 24 45 } 25 46 } 26 47 27 - export class SessionStore implements NodeSavedSessionStore { 28 - private store = new Map<string, NodeSavedSession>() 48 + export class CookieSessionStore implements NodeSavedSessionStore { 49 + constructor(private cookies: AstroCookies) {} 29 50 30 51 async get(key: string): Promise<NodeSavedSession | undefined> { 31 - return this.store.get(key) 52 + const cookieName = `oauth_session_${key}` 53 + const cookie = this.cookies.get(cookieName) 54 + if (!cookie?.value) return undefined 55 + 56 + try { 57 + const decoded = atob(cookie.value) 58 + return JSON.parse(decoded) as NodeSavedSession 59 + } catch (err) { 60 + console.warn('Failed to decode OAuth session:', err) 61 + return undefined 62 + } 32 63 } 33 64 34 65 async set(key: string, val: NodeSavedSession) { 35 - this.store.set(key, val) 66 + const cookieName = `oauth_session_${key}` 67 + const encoded = btoa(JSON.stringify(val)) 68 + 69 + this.cookies.set(cookieName, encoded, { 70 + httpOnly: true, 71 + secure: false, 72 + sameSite: 'lax', 73 + path: '/', 74 + maxAge: 60 * 60 * 24 * 30, // 30 days 75 + }) 36 76 } 37 77 38 78 async del(key: string) { 39 - this.store.delete(key) 79 + const cookieName = `oauth_session_${key}` 80 + this.cookies.delete(cookieName, { path: '/' }) 40 81 } 41 82 }
+4 -4
src/pages/api/login.ts
··· 1 1 import type { APIRoute } from 'astro' 2 - import { getAppContext } from '../../lib/context' 2 + import { getOAuthClient } from '../../lib/context' 3 3 4 - export const POST: APIRoute = async ({ request, redirect }) => { 4 + export const POST: APIRoute = async ({ request, cookies, redirect }) => { 5 5 try { 6 - const ctx = await getAppContext() 6 + const oauthClient = getOAuthClient(cookies) 7 7 const formData = await request.formData() 8 8 const handle = formData.get('handle') 9 9 ··· 11 11 return new Response('Invalid handle', { status: 400 }) 12 12 } 13 13 14 - const url = await ctx.oauthClient.authorize(handle, { 14 + const url = await oauthClient.authorize(handle, { 15 15 scope: 'atproto transition:generic', 16 16 }) 17 17
+3 -3
src/pages/api/logout.ts
··· 1 1 import type { APIRoute } from 'astro' 2 - import { getAppContext } from '../../lib/context' 2 + import { getOAuthClient } from '../../lib/context' 3 3 import { getSession } from '../../lib/session' 4 4 5 5 export const POST: APIRoute = async (context) => { 6 6 try { 7 - const ctx = await getAppContext() 7 + const oauthClient = getOAuthClient(context.cookies) 8 8 const session = getSession(context.cookies) 9 9 10 10 if (session.did) { 11 11 try { 12 - const oauthSession = await ctx.oauthClient.restore(session.did) 12 + const oauthSession = await oauthClient.restore(session.did) 13 13 if (oauthSession) await oauthSession.signOut() 14 14 } catch (err) { 15 15 console.warn('Failed to revoke credentials:', err)
+4 -4
src/pages/api/oauth/callback.ts
··· 1 1 import type { APIRoute } from 'astro' 2 - import { getAppContext } from '../../../lib/context' 2 + import { getOAuthClient } from '../../../lib/context' 3 3 import { getSession } from '../../../lib/session' 4 4 5 5 export const GET: APIRoute = async (context) => { 6 6 try { 7 - const ctx = await getAppContext() 7 + const oauthClient = getOAuthClient(context.cookies) 8 8 const url = new URL(context.request.url) 9 9 const params = new URLSearchParams(url.search) 10 10 ··· 12 12 13 13 if (session.did) { 14 14 try { 15 - const oauthSession = await ctx.oauthClient.restore(session.did) 15 + const oauthSession = await oauthClient.restore(session.did) 16 16 if (oauthSession) await oauthSession.signOut() 17 17 } catch (err) { 18 18 console.warn('OAuth restore failed during callback:', err) 19 19 } 20 20 } 21 21 22 - const oauth = await ctx.oauthClient.callback(params) 22 + const oauth = await oauthClient.callback(params) 23 23 session.did = oauth.session.did 24 24 await session.save() 25 25
+3 -3
src/pages/index.astro
··· 1 1 --- 2 2 import "../styles.css"; 3 3 import { getSession } from "../lib/session"; 4 - import { getAppContext } from "../lib/context"; 4 + import { getOAuthClient } from "../lib/context"; 5 5 import { Agent } from "@atproto/api"; 6 6 7 7 const session = getSession(Astro.cookies); 8 - const ctx = await getAppContext(); 8 + const oauthClient = getOAuthClient(Astro.cookies); 9 9 10 10 let agent: Agent | null = null; 11 11 let profile: any = null; 12 12 13 13 if (session.did) { 14 14 try { 15 - const oauthSession = await ctx.oauthClient.restore(session.did); 15 + const oauthSession = await oauthClient.restore(session.did); 16 16 if (oauthSession) { 17 17 agent = new Agent(oauthSession); 18 18