A minimal starter for ATProto logins in Astro

docs: Update Readme

Changed files
+124 -61
src
+48 -17
README.md
···
-
# Astro Starter Kit: Minimal
-
```sh
-
npm create astro@latest -- --template minimal
-
```
-
> ๐Ÿง‘โ€๐Ÿš€ **Seasoned astronaut?** Delete this file. Have fun!
-
## ๐Ÿš€ Project Structure
-
Inside of your Astro project, you'll see the following folders and files:
```text
/
โ”œโ”€โ”€ public/
โ”œโ”€โ”€ src/
-
โ”‚ โ””โ”€โ”€ pages/
-
โ”‚ โ””โ”€โ”€ index.astro
โ””โ”€โ”€ package.json
```
-
-
Astro looks for `.astro` or `.md` files in the `src/pages/` directory. Each page is exposed as a route based on its file name.
-
-
There's nothing special about `src/components/`, but that's where we like to put any Astro/React/Vue/Svelte/Preact components.
-
-
Any static assets, like images, can be placed in the `public/` directory.
## ๐Ÿงž Commands
···
| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
| `npm run astro -- --help` | Get help using the Astro CLI |
-
## ๐Ÿ‘€ Want to learn more?
-
Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat).
···
+
# Astro ATProto OAuth Starter
+
+
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.
+
+
This starter includes:
+
- Complete OAuth authentication flow using `@atproto/oauth-client-node`
+
- Cookie-based session management
+
- Profile display after authentication
+
- Login/logout endpoints
+
- Tailwind CSS and DaisyUI styling
+
+
## ๐Ÿš€ Getting Started
+
+
1. **Install dependencies:**
+
```sh
+
npm install
+
```
+
2. **Configure environment variables:**
+
```sh
+
cp .env.template .env
+
```
+
Edit `.env` if you need to change the port (default: 4321) or set a public URL.
+
3. **Start the development server:**
+
```sh
+
npm run dev
+
```
+
The app will be available at `http://localhost:4321`
+
4. **Try logging in:**
+
Enter your AT Protocol handle (e.g., `alice.bsky.social`) to authenticate.
+
## ๐Ÿ“ Project Structure
```text
/
โ”œโ”€โ”€ public/
โ”œโ”€โ”€ src/
+
โ”‚ โ”œโ”€โ”€ lib/
+
โ”‚ โ”‚ โ”œโ”€โ”€ context.ts # OAuth client singleton
+
โ”‚ โ”‚ โ”œโ”€โ”€ oauth.ts # OAuth client configuration
+
โ”‚ โ”‚ โ”œโ”€โ”€ session.ts # Session management
+
โ”‚ โ”‚ โ””โ”€โ”€ storage.ts # Cookie-based stores
+
โ”‚ โ”œโ”€โ”€ pages/
+
โ”‚ โ”‚ โ”œโ”€โ”€ api/
+
โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ login.ts # Login endpoint
+
โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ logout.ts # Logout endpoint
+
โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ oauth/
+
โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ callback.ts # OAuth callback handler
+
โ”‚ โ”‚ โ””โ”€โ”€ index.astro # Main page with login UI
+
โ”‚ โ””โ”€โ”€ styles.css
โ””โ”€โ”€ package.json
```
## ๐Ÿงž Commands
···
| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
| `npm run astro -- --help` | Get help using the Astro CLI |
+
## ๐Ÿ“š Learn More
+
- [Astro Documentation](https://docs.astro.build)
+
- [AT Protocol Documentation](https://atproto.com)
+
- [@atproto/oauth-client-node](https://github.com/bluesky-social/atproto/tree/main/packages/oauth/oauth-client-node)
+
- [Bluesky](https://bsky.app)
+4 -14
src/lib/context.ts
···
-
import { NodeOAuthClient } from '@atproto/oauth-client-node'
import { createOAuthClient } from './oauth'
-
export type AppContext = {
-
oauthClient: NodeOAuthClient
-
}
-
-
let _ctx: AppContext | null = null
-
-
export async function getAppContext(): Promise<AppContext> {
-
if (_ctx) return _ctx
-
-
const oauthClient = await createOAuthClient()
-
-
_ctx = { oauthClient }
-
return _ctx
}
···
+
import type { AstroCookies } from 'astro'
import { createOAuthClient } from './oauth'
+
// Create a request-scoped OAuth client with cookie-based storage
+
export function getOAuthClient(cookies: AstroCookies) {
+
return createOAuthClient(cookies)
}
+5 -4
src/lib/oauth.ts
···
import {
atprotoLoopbackClientMetadata,
NodeOAuthClient,
} from "@atproto/oauth-client-node";
import { env } from "./env";
-
import { SessionStore, StateStore } from "./storage";
-
export async function createOAuthClient() {
const clientMetadata = atprotoLoopbackClientMetadata(
`http://localhost?${new URLSearchParams([
["redirect_uri", `http://127.0.0.1:${env.PORT}/api/oauth/callback`],
···
return new NodeOAuthClient({
clientMetadata,
-
stateStore: new StateStore(),
-
sessionStore: new SessionStore(),
});
}
···
+
import type { AstroCookies } from 'astro'
import {
atprotoLoopbackClientMetadata,
NodeOAuthClient,
} from "@atproto/oauth-client-node";
import { env } from "./env";
+
import { CookieSessionStore, CookieStateStore } from "./storage";
+
export function createOAuthClient(cookies: AstroCookies) {
const clientMetadata = atprotoLoopbackClientMetadata(
`http://localhost?${new URLSearchParams([
["redirect_uri", `http://127.0.0.1:${env.PORT}/api/oauth/callback`],
···
return new NodeOAuthClient({
clientMetadata,
+
stateStore: new CookieStateStore(cookies),
+
sessionStore: new CookieSessionStore(cookies),
});
}
+53 -12
src/lib/storage.ts
···
import type {
NodeSavedSession,
NodeSavedSessionStore,
···
NodeSavedStateStore,
} from '@atproto/oauth-client-node'
-
// In-memory storage for OAuth state and sessions
-
// For production, you'd want to use a proper database or distributed cache
-
export class StateStore implements NodeSavedStateStore {
-
private store = new Map<string, NodeSavedState>()
async get(key: string): Promise<NodeSavedState | undefined> {
-
return this.store.get(key)
}
async set(key: string, val: NodeSavedState) {
-
this.store.set(key, val)
}
async del(key: string) {
-
this.store.delete(key)
}
}
-
export class SessionStore implements NodeSavedSessionStore {
-
private store = new Map<string, NodeSavedSession>()
async get(key: string): Promise<NodeSavedSession | undefined> {
-
return this.store.get(key)
}
async set(key: string, val: NodeSavedSession) {
-
this.store.set(key, val)
}
async del(key: string) {
-
this.store.delete(key)
}
}
···
+
import type { AstroCookies } from 'astro'
import type {
NodeSavedSession,
NodeSavedSessionStore,
···
NodeSavedStateStore,
} from '@atproto/oauth-client-node'
+
// Cookie-based storage for OAuth state and sessions
+
// All data is serialized into cookies for stateless operation
+
export class CookieStateStore implements NodeSavedStateStore {
+
constructor(private cookies: AstroCookies) {}
async get(key: string): Promise<NodeSavedState | undefined> {
+
const cookieName = `oauth_state_${key}`
+
const cookie = this.cookies.get(cookieName)
+
if (!cookie?.value) return undefined
+
+
try {
+
const decoded = atob(cookie.value)
+
return JSON.parse(decoded) as NodeSavedState
+
} catch (err) {
+
console.warn('Failed to decode OAuth state:', err)
+
return undefined
+
}
}
async set(key: string, val: NodeSavedState) {
+
const cookieName = `oauth_state_${key}`
+
const encoded = btoa(JSON.stringify(val))
+
+
this.cookies.set(cookieName, encoded, {
+
httpOnly: true,
+
secure: false,
+
sameSite: 'lax',
+
path: '/',
+
maxAge: 60 * 10, // 10 minutes (OAuth flow timeout)
+
})
}
async del(key: string) {
+
const cookieName = `oauth_state_${key}`
+
this.cookies.delete(cookieName, { path: '/' })
}
}
+
export class CookieSessionStore implements NodeSavedSessionStore {
+
constructor(private cookies: AstroCookies) {}
async get(key: string): Promise<NodeSavedSession | undefined> {
+
const cookieName = `oauth_session_${key}`
+
const cookie = this.cookies.get(cookieName)
+
if (!cookie?.value) return undefined
+
+
try {
+
const decoded = atob(cookie.value)
+
return JSON.parse(decoded) as NodeSavedSession
+
} catch (err) {
+
console.warn('Failed to decode OAuth session:', err)
+
return undefined
+
}
}
async set(key: string, val: NodeSavedSession) {
+
const cookieName = `oauth_session_${key}`
+
const encoded = btoa(JSON.stringify(val))
+
+
this.cookies.set(cookieName, encoded, {
+
httpOnly: true,
+
secure: false,
+
sameSite: 'lax',
+
path: '/',
+
maxAge: 60 * 60 * 24 * 30, // 30 days
+
})
}
async del(key: string) {
+
const cookieName = `oauth_session_${key}`
+
this.cookies.delete(cookieName, { path: '/' })
}
}
+4 -4
src/pages/api/login.ts
···
import type { APIRoute } from 'astro'
-
import { getAppContext } from '../../lib/context'
-
export const POST: APIRoute = async ({ request, redirect }) => {
try {
-
const ctx = await getAppContext()
const formData = await request.formData()
const handle = formData.get('handle')
···
return new Response('Invalid handle', { status: 400 })
}
-
const url = await ctx.oauthClient.authorize(handle, {
scope: 'atproto transition:generic',
})
···
import type { APIRoute } from 'astro'
+
import { getOAuthClient } from '../../lib/context'
+
export const POST: APIRoute = async ({ request, cookies, redirect }) => {
try {
+
const oauthClient = getOAuthClient(cookies)
const formData = await request.formData()
const handle = formData.get('handle')
···
return new Response('Invalid handle', { status: 400 })
}
+
const url = await oauthClient.authorize(handle, {
scope: 'atproto transition:generic',
})
+3 -3
src/pages/api/logout.ts
···
import type { APIRoute } from 'astro'
-
import { getAppContext } from '../../lib/context'
import { getSession } from '../../lib/session'
export const POST: APIRoute = async (context) => {
try {
-
const ctx = await getAppContext()
const session = getSession(context.cookies)
if (session.did) {
try {
-
const oauthSession = await ctx.oauthClient.restore(session.did)
if (oauthSession) await oauthSession.signOut()
} catch (err) {
console.warn('Failed to revoke credentials:', err)
···
import type { APIRoute } from 'astro'
+
import { getOAuthClient } from '../../lib/context'
import { getSession } from '../../lib/session'
export const POST: APIRoute = async (context) => {
try {
+
const oauthClient = getOAuthClient(context.cookies)
const session = getSession(context.cookies)
if (session.did) {
try {
+
const oauthSession = await oauthClient.restore(session.did)
if (oauthSession) await oauthSession.signOut()
} catch (err) {
console.warn('Failed to revoke credentials:', err)
+4 -4
src/pages/api/oauth/callback.ts
···
import type { APIRoute } from 'astro'
-
import { getAppContext } from '../../../lib/context'
import { getSession } from '../../../lib/session'
export const GET: APIRoute = async (context) => {
try {
-
const ctx = await getAppContext()
const url = new URL(context.request.url)
const params = new URLSearchParams(url.search)
···
if (session.did) {
try {
-
const oauthSession = await ctx.oauthClient.restore(session.did)
if (oauthSession) await oauthSession.signOut()
} catch (err) {
console.warn('OAuth restore failed during callback:', err)
}
}
-
const oauth = await ctx.oauthClient.callback(params)
session.did = oauth.session.did
await session.save()
···
import type { APIRoute } from 'astro'
+
import { getOAuthClient } from '../../../lib/context'
import { getSession } from '../../../lib/session'
export const GET: APIRoute = async (context) => {
try {
+
const oauthClient = getOAuthClient(context.cookies)
const url = new URL(context.request.url)
const params = new URLSearchParams(url.search)
···
if (session.did) {
try {
+
const oauthSession = await oauthClient.restore(session.did)
if (oauthSession) await oauthSession.signOut()
} catch (err) {
console.warn('OAuth restore failed during callback:', err)
}
}
+
const oauth = await oauthClient.callback(params)
session.did = oauth.session.did
await session.save()
+3 -3
src/pages/index.astro
···
---
import "../styles.css";
import { getSession } from "../lib/session";
-
import { getAppContext } from "../lib/context";
import { Agent } from "@atproto/api";
const session = getSession(Astro.cookies);
-
const ctx = await getAppContext();
let agent: Agent | null = null;
let profile: any = null;
if (session.did) {
try {
-
const oauthSession = await ctx.oauthClient.restore(session.did);
if (oauthSession) {
agent = new Agent(oauthSession);
···
---
import "../styles.css";
import { getSession } from "../lib/session";
+
import { getOAuthClient } from "../lib/context";
import { Agent } from "@atproto/api";
const session = getSession(Astro.cookies);
+
const oauthClient = getOAuthClient(Astro.cookies);
let agent: Agent | null = null;
let profile: any = null;
if (session.did) {
try {
+
const oauthSession = await oauthClient.restore(session.did);
if (oauthSession) {
agent = new Agent(oauthSession);