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 OAuth routes and controller for Bluesky sign-in

Implements /oauth/client-metadata.json, /oauth/login, /oauth/callback,
and /oauth/logout routes with an OAuthController that uses DI to get the
AtprotoOAuthService singleton. Includes login form template, shield API
client plugin for CSRF-aware tests, and functional test coverage.

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

+273
+1
.adonisjs/server/controllers.ts
··· 6 6 export const controllers = { 7 7 HealthChecks: () => import('#controllers/health_checks_controller'), 8 8 Landing: () => import('#controllers/landing_controller'), 9 + Oauth: () => import('#controllers/oauth_controller'), 9 10 Profile: () => import('#controllers/profile_controller'), 10 11 Search: () => import('#controllers/search_controller'), 11 12 Typeahead: () => import('#controllers/typeahead_controller'),
+113
app/controllers/oauth_controller.ts
··· 1 + import { inject } from '@adonisjs/core' 2 + import type { HttpContext } from '@adonisjs/core/http' 3 + import logger from '@adonisjs/core/services/logger' 4 + import AtprotoOAuthService from '#services/atproto_oauth' 5 + import Account from '#models/account' 6 + 7 + @inject() 8 + export default class OAuthController { 9 + constructor(private readonly oauthService: AtprotoOAuthService) {} 10 + 11 + /** 12 + * GET /oauth/client-metadata.json 13 + * Returns the OAuth client metadata document required by the atproto OAuth spec. 14 + */ 15 + async clientMetadata({ response }: HttpContext) { 16 + const metadata = this.oauthService.getClientMetadata() 17 + response.header('Cache-Control', 'public, max-age=86400') 18 + return response.json(metadata) 19 + } 20 + 21 + /** 22 + * GET /oauth/login 23 + * If ?handle is provided, starts the OAuth flow. Otherwise renders a login form. 24 + */ 25 + async login({ request, response, session, view }: HttpContext) { 26 + const handle = request.input('handle') 27 + 28 + if (!handle) { 29 + return view.render('pages/oauth/login') 30 + } 31 + 32 + // Store return URL so we can redirect back after login 33 + const referer = request.header('referer') 34 + if (referer) { 35 + try { 36 + const url = new URL(referer) 37 + session.put('oauth_return_url', url.pathname + url.search) 38 + } catch { 39 + // Invalid referer, ignore 40 + } 41 + } 42 + 43 + try { 44 + const authUrl = await this.oauthService.authorize(handle) 45 + return response.redirect(authUrl) 46 + } catch (error) { 47 + logger.error({ error, handle }, 'OAuth authorize failed') 48 + session.flash('error', 'Could not start sign-in. Check the handle and try again.') 49 + return response.redirect().toPath('/oauth/login') 50 + } 51 + } 52 + 53 + /** 54 + * GET /oauth/callback 55 + * Handles the OAuth callback from the Bluesky authorization server. 56 + */ 57 + async callback({ request, response, session, auth }: HttpContext) { 58 + try { 59 + const url = new URL(request.completeUrl()) 60 + const params = url.searchParams 61 + 62 + const { did, handle } = await this.oauthService.callback(params) 63 + 64 + // Find or create the account 65 + const now = Date.now() 66 + const account = await Account.updateOrCreate( 67 + { did }, 68 + { 69 + did, 70 + handle, 71 + updatedAt: now, 72 + } 73 + ) 74 + if (!account.createdAt) { 75 + account.createdAt = now 76 + await account.save() 77 + } 78 + 79 + // Log them in 80 + await auth.use('web').login(account) 81 + 82 + // Redirect to stored return URL, or profile, or home 83 + const returnUrl = session.pull('oauth_return_url') 84 + if (returnUrl && returnUrl !== '/') { 85 + return response.redirect().toPath(returnUrl) 86 + } 87 + 88 + // If they came from the landing page (or no stored URL), go to their profile 89 + return response.redirect().toPath(`/profile/${handle}/likes`) 90 + } catch (error) { 91 + logger.error({ error }, 'OAuth callback failed') 92 + session.flash('error', 'Sign-in failed. Please try again.') 93 + return response.redirect().toPath('/') 94 + } 95 + } 96 + 97 + /** 98 + * POST /oauth/logout 99 + * Revokes the atproto session and logs the user out. 100 + */ 101 + async logout({ auth, response }: HttpContext) { 102 + const user = auth.use('web').user! 103 + 104 + try { 105 + await this.oauthService.revoke(user.did) 106 + } catch (error) { 107 + logger.error({ error, did: user.did }, 'OAuth revoke failed (continuing with logout)') 108 + } 109 + 110 + await auth.use('web').logout() 111 + return response.redirect().toPath('/') 112 + } 113 + }
+40
resources/views/pages/oauth/login.edge
··· 1 + @component('components/layout') 2 + @slot('title') 3 + Sign in — favs.blue 4 + @endslot 5 + @slot('main') 6 + <div class="pt-8 pb-12 max-w-sm mx-auto"> 7 + <h1 class="font-heading text-2xl font-bold mb-2 tracking-tight">Sign in with Bluesky</h1> 8 + <p class="text-sm text-gray-500 dark:text-gray-400 mb-6"> 9 + Enter your Bluesky handle to sign in. 10 + </p> 11 + 12 + @if(flashMessages.has('error')) 13 + <div class="mb-4 p-3 rounded-md bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400 text-sm"> 14 + {{ flashMessages.get('error') }} 15 + </div> 16 + @endif 17 + 18 + <form action="/oauth/login" method="GET"> 19 + <label for="handle" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> 20 + Handle 21 + </label> 22 + <input 23 + type="text" 24 + id="handle" 25 + name="handle" 26 + placeholder="you.bsky.social" 27 + required 28 + autofocus 29 + class="w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500" 30 + /> 31 + <button 32 + type="submit" 33 + class="mt-3 w-full rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors" 34 + > 35 + Sign in 36 + </button> 37 + </form> 38 + </div> 39 + @endslot 40 + @endcomponent
+24
start/routes.ts
··· 8 8 */ 9 9 10 10 import router from '@adonisjs/core/services/router' 11 + import { middleware } from '#start/kernel' 11 12 12 13 const LandingController = () => import('#controllers/landing_controller') 13 14 const SearchController = () => import('#controllers/search_controller') 14 15 const TypeaheadController = () => import('#controllers/typeahead_controller') 15 16 const ProfileController = () => import('#controllers/profile_controller') 17 + const OAuthController = () => import('#controllers/oauth_controller') 16 18 const HealthChecksController = () => import('#controllers/health_checks_controller') 17 19 18 20 // --------------------------------------------------------------------------- ··· 52 54 router 53 55 .get('/profile/:handle/backfill/stream', [ProfileController, 'backfillStream']) 54 56 .as('profile.backfill.stream') 57 + 58 + // --------------------------------------------------------------------------- 59 + // OAuth 60 + // --------------------------------------------------------------------------- 61 + 62 + router 63 + .get('/oauth/client-metadata.json', [OAuthController, 'clientMetadata']) 64 + .as('oauth.clientMetadata') 65 + 66 + router 67 + .get('/oauth/login', [OAuthController, 'login']) 68 + .use(middleware.guest()) 69 + .as('oauth.login') 70 + 71 + router 72 + .get('/oauth/callback', [OAuthController, 'callback']) 73 + .as('oauth.callback') 74 + 75 + router 76 + .post('/oauth/logout', [OAuthController, 'logout']) 77 + .use(middleware.auth()) 78 + .as('oauth.logout') 55 79 56 80 // --------------------------------------------------------------------------- 57 81 // Health checks
+2
tests/bootstrap.ts
··· 7 7 import { sessionApiClient } from '@adonisjs/session/plugins/api_client' 8 8 import { authApiClient } from '@adonisjs/auth/plugins/api_client' 9 9 import { dbAssertions } from '@adonisjs/lucid/plugins/db' 10 + import { shieldApiClient } from '@adonisjs/shield/plugins/api_client' 10 11 import testUtils from '@adonisjs/core/services/test_utils' 11 12 12 13 /** ··· 23 24 sessionApiClient(app), 24 25 authApiClient(app), 25 26 pluginAdonisJS(app), 27 + shieldApiClient(), 26 28 dbAssertions(app), 27 29 browserClient({ 28 30 runInSuites: ['browser'],
+93
tests/functional/oauth.spec.ts
··· 1 + /** 2 + * Functional tests for OAuth controller routes. 3 + * 4 + * We cannot test the full authorize/callback flow since it requires real 5 + * Bluesky servers. Focus on what's testable: metadata endpoint, login form, 6 + * and logout guards. 7 + */ 8 + import { test } from '@japa/runner' 9 + import testUtils from '@adonisjs/core/services/test_utils' 10 + import Account from '#models/account' 11 + 12 + test.group('OAuth | client-metadata.json', () => { 13 + test('returns valid metadata JSON with correct fields', async ({ client, assert }) => { 14 + const response = await client.get('/oauth/client-metadata.json') 15 + response.assertStatus(200) 16 + response.assertHeader('content-type', 'application/json; charset=utf-8') 17 + 18 + const body = response.body() 19 + assert.property(body, 'client_id') 20 + assert.property(body, 'client_name') 21 + assert.property(body, 'redirect_uris') 22 + assert.property(body, 'scope') 23 + assert.property(body, 'grant_types') 24 + assert.property(body, 'response_types') 25 + assert.property(body, 'token_endpoint_auth_method') 26 + assert.property(body, 'application_type') 27 + assert.property(body, 'dpop_bound_access_tokens') 28 + 29 + assert.equal(body.client_name, 'favs.blue') 30 + assert.equal(body.application_type, 'web') 31 + assert.isTrue(body.dpop_bound_access_tokens) 32 + assert.include(body.client_id, '/oauth/client-metadata.json') 33 + assert.isArray(body.redirect_uris) 34 + assert.include(body.redirect_uris[0], '/oauth/callback') 35 + }) 36 + 37 + test('sets cache-control header', async ({ client }) => { 38 + const response = await client.get('/oauth/client-metadata.json') 39 + response.assertStatus(200) 40 + const cacheControl = response.header('cache-control') 41 + // Should have some caching 42 + response.assert?.include(cacheControl, 'max-age') 43 + }) 44 + }) 45 + 46 + test.group('OAuth | login', () => { 47 + test('renders login form when no handle is provided', async ({ client }) => { 48 + const response = await client.get('/oauth/login') 49 + response.assertStatus(200) 50 + response.assertTextIncludes('handle') 51 + }) 52 + 53 + test('redirects authenticated users away (guest middleware)', async ({ client }) => { 54 + const account = await Account.create({ 55 + did: 'did:plc:oauthtest1', 56 + handle: 'oauthtest.bsky.social', 57 + sessionData: '{}', 58 + createdAt: Date.now(), 59 + updatedAt: Date.now(), 60 + }) 61 + 62 + const response = await client.get('/oauth/login').loginAs(account).redirects(0) 63 + response.assertStatus(302) 64 + }).setup(() => testUtils.db().withGlobalTransaction()) 65 + }) 66 + 67 + test.group('OAuth | logout', (group) => { 68 + group.each.setup(() => testUtils.db().withGlobalTransaction()) 69 + 70 + test('unauthenticated POST to logout returns 302 redirect', async ({ client }) => { 71 + const response = await client.post('/oauth/logout').redirects(0).withCsrfToken() 72 + response.assertStatus(302) 73 + }) 74 + 75 + test('authenticated POST to logout clears session and redirects', async ({ client }) => { 76 + const account = await Account.create({ 77 + did: 'did:plc:oauthlogout1', 78 + handle: 'logouttest.bsky.social', 79 + sessionData: '{}', 80 + createdAt: Date.now(), 81 + updatedAt: Date.now(), 82 + }) 83 + 84 + const response = await client 85 + .post('/oauth/logout') 86 + .loginAs(account) 87 + .withCsrfToken() 88 + .redirects(0) 89 + 90 + response.assertStatus(302) 91 + response.assertHeader('location', '/') 92 + }) 93 + })