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