A website for the ATmosphereConf
13
fork

Configure Feed

Select the types of activity you want to include in your feed.

Once more with README merges

authored by

daffl and committed by
Boris Mann
b712e2ba f670c9a8

+124 -61
+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