See the best posts from any Bluesky account
0
fork

Configure Feed

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

Add atproto OAuth service with SQLite-backed storage adapters

Build AtprotoOAuthService wrapping NodeOAuthClient with state store
(AuthState model) and session store (Account model). Expose authorize,
callback, getAgent, revoke, and getClientMetadata methods. Register
as singleton via provider. Tests cover storage CRUD and metadata shape.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+315
+1
adonisrc.ts
··· 65 65 () => import('@adonisjs/queue/queue_provider'), 66 66 () => import('@adonisjs/auth/auth_provider'), 67 67 () => import('#providers/atproto_provider'), 68 + () => import('#providers/atproto_oauth_provider'), 68 69 () => import('#providers/clickhouse_provider'), 69 70 ], 70 71
+164
app/services/atproto_oauth.ts
··· 1 + import { NodeOAuthClient } from '@atproto/oauth-client-node' 2 + import type { NodeSavedSession, NodeSavedState } from '@atproto/oauth-client-node' 3 + import { Agent } from '@atproto/api' 4 + import env from '#start/env' 5 + import Account from '#models/account' 6 + import AuthState from '#models/auth_state' 7 + 8 + /** 9 + * Build OAuth client metadata for a public (non-confidential) AT Protocol client. 10 + */ 11 + export function buildClientMetadata(appUrl: string) { 12 + return { 13 + client_id: `${appUrl}/oauth/client-metadata.json`, 14 + client_name: 'favs.blue', 15 + client_uri: appUrl, 16 + redirect_uris: [`${appUrl}/oauth/callback`] as [string], 17 + scope: 18 + 'atproto repo:app.bsky.feed.like?action=create&action=delete repo:app.bsky.feed.repost?action=create&action=delete', 19 + grant_types: ['authorization_code', 'refresh_token'] as ['authorization_code', 'refresh_token'], 20 + response_types: ['code'] as ['code'], 21 + token_endpoint_auth_method: 'none' as const, 22 + application_type: 'web' as const, 23 + dpop_bound_access_tokens: true, 24 + } 25 + } 26 + 27 + /** 28 + * Create a SimpleStore<string, NodeSavedState> backed by the AuthState model. 29 + */ 30 + export function createStateStore() { 31 + return { 32 + async get(key: string): Promise<NodeSavedState | undefined> { 33 + const row = await AuthState.find(key) 34 + if (!row) return undefined 35 + return JSON.parse(row.stateData) as NodeSavedState 36 + }, 37 + 38 + async set(key: string, value: NodeSavedState): Promise<void> { 39 + await AuthState.updateOrCreate( 40 + { key }, 41 + { 42 + key, 43 + stateData: JSON.stringify(value), 44 + createdAt: Date.now(), 45 + } 46 + ) 47 + }, 48 + 49 + async del(key: string): Promise<void> { 50 + const row = await AuthState.find(key) 51 + if (row) await row.delete() 52 + }, 53 + } 54 + } 55 + 56 + /** 57 + * Create a SimpleStore<string, NodeSavedSession> backed by the Account model. 58 + * The key is the user's DID. 59 + */ 60 + export function createSessionStore() { 61 + return { 62 + async get(key: string): Promise<NodeSavedSession | undefined> { 63 + const account = await Account.find(key) 64 + if (!account || !account.sessionData) return undefined 65 + return JSON.parse(account.sessionData) as NodeSavedSession 66 + }, 67 + 68 + async set(key: string, value: NodeSavedSession): Promise<void> { 69 + const now = Date.now() 70 + await Account.updateOrCreate( 71 + { did: key }, 72 + { 73 + did: key, 74 + handle: key, 75 + sessionData: JSON.stringify(value), 76 + createdAt: now, 77 + updatedAt: now, 78 + } 79 + ) 80 + }, 81 + 82 + async del(key: string): Promise<void> { 83 + const account = await Account.find(key) 84 + if (account) { 85 + account.sessionData = '' 86 + account.updatedAt = Date.now() 87 + await account.save() 88 + } 89 + }, 90 + } 91 + } 92 + 93 + /** 94 + * AdonisJS service wrapping NodeOAuthClient with SQLite-backed storage. 95 + * Lazy-initializes the client on first use. 96 + */ 97 + export default class AtprotoOAuthService { 98 + #client: NodeOAuthClient | null = null 99 + 100 + get client(): NodeOAuthClient { 101 + if (!this.#client) { 102 + const appUrl = env.get('APP_URL') 103 + this.#client = new NodeOAuthClient({ 104 + clientMetadata: buildClientMetadata(appUrl), 105 + stateStore: createStateStore(), 106 + sessionStore: createSessionStore(), 107 + }) 108 + } 109 + return this.#client 110 + } 111 + 112 + /** 113 + * Returns the client metadata JSON (for the /oauth/client-metadata.json endpoint). 114 + */ 115 + getClientMetadata() { 116 + return buildClientMetadata(env.get('APP_URL')) 117 + } 118 + 119 + /** 120 + * Start the OAuth authorization flow. Returns the authorization URL as a string. 121 + */ 122 + async authorize(handle: string): Promise<string> { 123 + const url = await this.client.authorize(handle) 124 + return url.toString() 125 + } 126 + 127 + /** 128 + * Process the OAuth callback. Returns the user's DID and handle. 129 + */ 130 + async callback(params: URLSearchParams): Promise<{ did: string; handle: string }> { 131 + const { session } = await this.client.callback(params) 132 + const did = session.sub 133 + 134 + // Resolve handle from the session's DID 135 + const agent = new Agent(session) 136 + const profile = await agent.getProfile({ actor: did }) 137 + const handle = profile.data.handle 138 + 139 + // Update the account's handle 140 + const account = await Account.find(did) 141 + if (account) { 142 + account.handle = handle 143 + account.updatedAt = Date.now() 144 + await account.save() 145 + } 146 + 147 + return { did, handle } 148 + } 149 + 150 + /** 151 + * Restore a session and return an @atproto/api Agent. 152 + */ 153 + async getAgent(did: string): Promise<Agent> { 154 + const session = await this.client.restore(did) 155 + return new Agent(session) 156 + } 157 + 158 + /** 159 + * Revoke and clean up a session. 160 + */ 161 + async revoke(did: string): Promise<void> { 162 + await this.client.revoke(did) 163 + } 164 + }
+15
providers/atproto_oauth_provider.ts
··· 1 + import type { ApplicationService } from '@adonisjs/core/types' 2 + import AtprotoOAuthService from '#services/atproto_oauth' 3 + 4 + /** 5 + * Registers AtprotoOAuthService as a container singleton. 6 + */ 7 + export default class AtprotoOAuthProvider { 8 + constructor(protected app: ApplicationService) {} 9 + 10 + register() { 11 + this.app.container.singleton(AtprotoOAuthService, () => { 12 + return new AtprotoOAuthService() 13 + }) 14 + } 15 + }
+135
tests/unit/atproto_oauth_storage.spec.ts
··· 1 + import { test } from '@japa/runner' 2 + import testUtils from '@adonisjs/core/services/test_utils' 3 + import Account from '#models/account' 4 + import { 5 + createStateStore, 6 + createSessionStore, 7 + buildClientMetadata, 8 + } from '#services/atproto_oauth' 9 + 10 + test.group('AtprotoOAuth — state store', (group) => { 11 + group.each.setup(() => testUtils.db().withGlobalTransaction()) 12 + 13 + test('set persists state and get retrieves it', async ({ assert }) => { 14 + const store = createStateStore() 15 + const fakeState = { dpopKey: { kty: 'EC' }, iss: 'https://bsky.social', verifier: 'abc' } 16 + 17 + await store.set('state_123', fakeState as any) 18 + 19 + const result = await store.get('state_123') 20 + assert.deepEqual(result, fakeState) 21 + }) 22 + 23 + test('get returns undefined for missing key', async ({ assert }) => { 24 + const store = createStateStore() 25 + const result = await store.get('nonexistent') 26 + assert.isUndefined(result) 27 + }) 28 + 29 + test('del removes the state', async ({ assert }) => { 30 + const store = createStateStore() 31 + const fakeState = { dpopKey: { kty: 'EC' }, iss: 'https://bsky.social', verifier: 'abc' } 32 + 33 + await store.set('state_del', fakeState as any) 34 + await store.del('state_del') 35 + 36 + const result = await store.get('state_del') 37 + assert.isUndefined(result) 38 + }) 39 + 40 + test('del is a no-op for missing key', async ({ assert }) => { 41 + const store = createStateStore() 42 + // Should not throw 43 + await store.del('nonexistent') 44 + assert.isTrue(true) 45 + }) 46 + }) 47 + 48 + test.group('AtprotoOAuth — session store', (group) => { 49 + group.each.setup(() => testUtils.db().withGlobalTransaction()) 50 + 51 + test('set creates an account and get retrieves session data', async ({ assert }) => { 52 + const store = createSessionStore() 53 + const fakeSession = { 54 + dpopKey: { kty: 'EC' }, 55 + tokenSet: { access_token: 'at', refresh_token: 'rt', sub: 'did:plc:session1' }, 56 + } 57 + 58 + await store.set('did:plc:session1', fakeSession as any) 59 + 60 + const result = await store.get('did:plc:session1') 61 + assert.deepEqual(result, fakeSession) 62 + }) 63 + 64 + test('set updates existing account session data', async ({ assert }) => { 65 + const store = createSessionStore() 66 + const now = Date.now() 67 + await Account.create({ 68 + did: 'did:plc:existing', 69 + handle: 'existing.bsky.social', 70 + sessionData: '{"old":"data"}', 71 + createdAt: now, 72 + updatedAt: now, 73 + }) 74 + 75 + const newSession = { 76 + dpopKey: { kty: 'EC' }, 77 + tokenSet: { access_token: 'new_at', sub: 'did:plc:existing' }, 78 + } 79 + await store.set('did:plc:existing', newSession as any) 80 + 81 + const result = await store.get('did:plc:existing') 82 + assert.deepEqual(result, newSession) 83 + }) 84 + 85 + test('get returns undefined for missing DID', async ({ assert }) => { 86 + const store = createSessionStore() 87 + const result = await store.get('did:plc:missing') 88 + assert.isUndefined(result) 89 + }) 90 + 91 + test('del clears session data on account', async ({ assert }) => { 92 + const store = createSessionStore() 93 + const now = Date.now() 94 + await Account.create({ 95 + did: 'did:plc:delme', 96 + handle: 'delme.bsky.social', 97 + sessionData: '{"some":"data"}', 98 + createdAt: now, 99 + updatedAt: now, 100 + }) 101 + 102 + await store.del('did:plc:delme') 103 + 104 + const account = await Account.find('did:plc:delme') 105 + assert.isNotNull(account) 106 + // sessionData should be cleared 107 + assert.equal(account!.sessionData, '') 108 + }) 109 + 110 + test('del is a no-op for missing DID', async ({ assert }) => { 111 + const store = createSessionStore() 112 + await store.del('did:plc:nonexistent') 113 + assert.isTrue(true) 114 + }) 115 + }) 116 + 117 + test.group('AtprotoOAuth — client metadata', () => { 118 + test('returns expected metadata shape', ({ assert }) => { 119 + const meta = buildClientMetadata('https://favs.blue') 120 + 121 + assert.equal(meta.client_id, 'https://favs.blue/oauth/client-metadata.json') 122 + assert.equal(meta.client_name, 'favs.blue') 123 + assert.equal(meta.client_uri, 'https://favs.blue') 124 + assert.deepEqual(meta.redirect_uris, ['https://favs.blue/oauth/callback']) 125 + assert.equal( 126 + meta.scope, 127 + 'atproto repo:app.bsky.feed.like?action=create&action=delete repo:app.bsky.feed.repost?action=create&action=delete' 128 + ) 129 + assert.deepEqual(meta.grant_types, ['authorization_code', 'refresh_token']) 130 + assert.deepEqual(meta.response_types, ['code']) 131 + assert.equal(meta.token_endpoint_auth_method, 'none') 132 + assert.equal(meta.application_type, 'web') 133 + assert.equal(meta.dpop_bound_access_tokens, true) 134 + }) 135 + })