Implementing Bluesky OAuth Authentication in Nextjs & BetterAuth
NextJsoauth

Implementing Bluesky OAuth Authentication in Nextjs & BetterAuth


Unlike traditional OAuth where you register your app on a central dashboard, Bluesky is a distributed network of servers and there is no central authority to it. This presents new challenges.

How Bluesky OAuth Works

1. Client Registration

  • Unlike traditional OAuth where you register your app on a dashboard, Bluesky uses a self-hosted client metadata file approach.

  • Your app hosts a metadata file at a publicly accessible URL (e.g., /client-metadata.json).

  • The metadata includes your app's name, redirect URIs, and JWKS endpoint.

  • URL of this metadata file will be the client_id value.

2. Key Generation

  • Your app needs to generate an ES256 (Elliptic Curve) key pair

  • The private key is used to sign authentication requests

  • The public key is exposed through a JWKS (JSON Web Key Set) endpoint

  • Bluesky uses this to verify your app's identity

3. Authorization Flow

  • User clicks "Connect with Bluesky"

  • Your app initiates the OAuth flow by redirecting to Bluesky's authorization endpoint

  • The request includes the user's handle and a state parameter for security

  • Bluesky verifies your app's metadata and JWKS

Token Exchange

  • After user approval, Bluesky redirects back to your callback URL

  • Your app exchanges the authorization code for access and refresh tokens

  • The token request is signed using your private key

  • Tokens are DPoP-bound for enhanced security

Session Management

  • Store the access token, refresh token, and expiration time

  • Use refresh tokens to maintain long-term access

  • Implement token rotation for security

First Step: Key generation

Go to https://jwkset.com/generate and generate a new key.

Key type: ECDSA

Key algorithm: ES256

Key use: Signature

Keep the JSON Web Key (JWK format) of it. You will need it. It will be something like this:

{
  "kty": "EC",
  "use": "sig",
  "alg": "ES256",
  "kid": "111111-6666-4557-7777-bf9696969696",
  "crv": "P-256",
  "x": "123123123123",
  "y": "456456456456456456456",
  "d": "79789789789789797"
}

Implementation

The implementation requires four endpoints:

  • /client-metadata.json - Serves your OAuth client metadata
  • /jwks.json - Serves your public key in JWKS format
  • /api/auth/bluesky - Initiates the OAuth flow
  • /api/auth/bluesky/callback - Handles the OAuth callback

client-metadata.json endpoint

This is where you self-host your client metadata file, Bluesky calls this endpoint each time to verify the identity of your app. It's like this:

{
    client_id: "https://your-app.com/client-metadata.json",
    application_type: "web",
    client_name: "your-app-name",
    redirect_uris: ["https://your-app.com/api/auth/bluesky/callback"],
    grant_types: ["authorization_code", "refresh_token"],
    response_types: ["code"],
    scope: "atproto transition:generic",
    dpop_bound_access_tokens: true,
    token_endpoint_auth_method: "private_key_jwt",
    jwks_uri: "https://your-app.com/jwks.json",
    token_endpoint_auth_signing_alg: "ES256",
}

replace your-app.com address with your actual app url.

jwks.json endpoint

this endpoint can be a static file, or a dynamic one. it should serve the JWK format you got from step before without the "d" property of it. "d" is the private key and YOU SHOULD NOT EXPOSE IT ANYWHERE.

It will be something like this:

{
  "kty": "EC",
  "use": "sig",
  "alg": "ES256",
  "kid": "111111-6666-4557-7777-bf9696969696",
  "crv": "P-256",
  "x": "123123123123",
  "y": "456456456456456456456"
}

Define BlueskyOAuthClient

I decided to use @atproto/oauth-client-node npm package (it's official from bluesky team) to facilitate this for me.

npm i @atproto/oauth-client-node

This npm package needs to be setup.

export const BlueskyOAuthClient = new NodeOAuthClient({
 
// same metadata as you exposed in /client-metadata.json endpoint
    clientMetadata: {
    client_id: "https://your-app.com/client-metadata.json",
    application_type: "web",
    client_name: "your-app-name",
    redirect_uris: ["https://your-app.com/api/auth/bluesky/callback"],
    grant_types: ["authorization_code", "refresh_token"],
    response_types: ["code"],
    scope: "atproto transition:generic",
    dpop_bound_access_tokens: true,
    token_endpoint_auth_method: "private_key_jwt",
    jwks_uri: "https://your-app.com/jwks.json",
    token_endpoint_auth_signing_alg: "ES256",
},
// JWK WITH "d" field 
    keyset: await Promise.all([
        JoseKey.fromImportable(`{
  "kty": "EC",
  "use": "sig",
  "alg": "ES256",
  "kid": "111111-6666-4557-7777-bf9696969696",
  "crv": "P-256",
  "x": "123123123123",
  "y": "456456456456456456456",
  "d": "79789789789789797"
}`),
    ]),
    stateStore: {
        async set(key: string, internalState: NodeSavedState): Promise<void> {
            const cookieStore = await cookies();
            cookieStore.set('oauth_state', JSON.stringify(internalState), {
                httpOnly: true,
                secure: process.env.NODE_ENV === 'production',
                sameSite: 'lax',
                maxAge: 60 * 10, // 10 minutes
            });
        },
        async get(key: string): Promise<NodeSavedState | undefined> {
            const cookieStore = await cookies();
            const state = cookieStore.get('oauth_state');
            return state ? JSON.parse(state.value) : undefined;
        },
        async del(key: string): Promise<void> {
            const cookieStore = await cookies();
            cookieStore.delete('oauth_state');
        },
    },
 
    // Interface to store authenticated session data
    sessionStore: {
        async set(sub: string, session: NodeSavedSession): Promise<void> {
            // Insert or Update the session in BetterAuth 'accounts' table
	    // use sub as AccountId field
        },
        async get(sub: string): Promise<NodeSavedSession | undefined> {
            // Read it back from BetterAuth 'accounts' table.
            // search for AccountId=sub
        },
        async del(sub: string): Promise<void> {
            // delete the record from BetterAuth 'accounts' table
            // use AccountsId=sub for criteria
        },
    },
});

auth/bluesky endpoint

This is the endpoint where all the fun starts. User is redirected to this endpoint so that its oauth flow starts. in order to initate an oauth flow, you need to ask for user's bluesky handle. It is needed to find out the server this user is on. (Not all users are on bsky.social server).

Then your endpoint will be like:

export async function GET(request: NextRequest) {
    const handle = // get it from request
    const state = // from request OR undefined
 
    // Initialize the OAuth flow
    const url = await BlueskyOAuthClient.authorize(handle, { state });
 
    return NextResponse.redirect(url);
}

If you've done everything correctly, the user will be redirect to bluesky, and gets authorized there. After that, it is redirected to the "redirect_uri" you set in your metadata file.

auth/bluesky/callback endpoint

This is easy. it looks like this:

export async function GET(request: NextRequest) {
    const searchParams = request.nextUrl.searchParams;
    const { session, state } = await BlueskyOAuthClient.callback(searchParams);
 
    console.log('authorize() was called with state:', state);
 
    console.log('User authenticated as:', session.did);
 
    return NextResponse.redirect(request.nextUrl.origin);
}

And Voila!

Now you have Bluesky oauth works perfectly with your Nextjs 15+ and BetterAuth. You can use it like this:

const blueskySession = await BlueskyOAuthClient.restore("bluesky did");
const agent = new Agent(session);
await agent.post(tweetText);

BlueskyOAuthClient takes care of refreshing your token automatically behind the hood.

Notes

  1. Remember that the kid (Key ID) values must match between your private keys (the one you set in NodeOAuthClient setup) and the public JWKS file (jwks.json file).
  2. Make sure your keys are in the correct ES256 format
  3. Always follow best security practices and store your key in safe places. The code here is only for demonstration and should not be used for production.
  4. For local development you can use ngrok to give you a public URL you can use.
  5. Keep your private keys ("d" field in JWK) secure and never commit them to version control.