+48
-17
README.md
+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
+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
+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
+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
+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
+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
+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
+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