No description
Find a file
LeNooby09 a90140887d
All checks were successful
Build Shadow JAR / build (push) Successful in 1m32s
Update default ATPROTO_SCOPE to include rpc:app.bsky.actor.getProfile for OIDC compliance
- Added `rpc:app.bsky.actor.getProfile?aud=did:web:api.bsky.app#bsky_appview` to the default scope to support Bluesky profile fetching for OIDC `name` and `picture` claims.
- Updated tests, documentation, and configuration templates to reflect the new scope requirement.
- Added regression tests to ensure `rpc:app.bsky.actor.getProfile` is included in custom scopes, preventing ScopeMissingError 403 responses.
2026-05-01 20:26:44 +00:00
.forgejo/workflows Implement OAuth2 bridge for ATProto and Forgejo 2026-03-20 21:01:38 +01:00
.idea Update default ATPROTO_SCOPE to include rpc:app.bsky.actor.getProfile for OIDC compliance 2026-05-01 20:26:44 +00:00
gradle/wrapper Implement OAuth2 bridge for ATProto and Forgejo 2026-03-20 21:01:38 +01:00
src Update default ATPROTO_SCOPE to include rpc:app.bsky.actor.getProfile for OIDC compliance 2026-05-01 20:26:44 +00:00
.gitignore Implement OAuth2 bridge for ATProto and Forgejo 2026-03-20 21:01:38 +01:00
build.gradle.kts Add unit tests for OAuth client, token service, configuration, and avatar logic 2026-05-01 20:04:47 +00:00
gradle.properties Implement OAuth2 bridge for ATProto and Forgejo 2026-03-20 21:01:38 +01:00
gradlew Implement OAuth2 bridge for ATProto and Forgejo 2026-03-20 21:01:38 +01:00
gradlew.bat Implement OAuth2 bridge for ATProto and Forgejo 2026-03-20 21:01:38 +01:00
README.md Update default ATPROTO_SCOPE to include rpc:app.bsky.actor.getProfile for OIDC compliance 2026-05-01 20:26:44 +00:00
settings.gradle.kts Implement OAuth2 bridge for ATProto and Forgejo 2026-03-20 21:01:38 +01:00

ATProto/Bluesky → Forgejo OAuth2 Bridge

An intermediate OAuth2 application that enables Bluesky (ATProto) authentication for Forgejo instances. Since Forgejo doesn't natively support Bluesky as an authentication provider, this bridge acts as an OpenID Connect provider that Forgejo can use, while authenticating users via Bluesky's ATProto OAuth2 flow.

How It Works

Forgejo ──(OIDC)──► This Bridge ──(ATProto OAuth2)──► Bluesky Auth Server
  1. User clicks "Sign in with Bluesky" on Forgejo
  2. Forgejo redirects to this bridge's /authorize endpoint
  3. User enters their Bluesky handle (e.g., alice.bsky.social)
  4. The bridge resolves the handle → DID → PDS → Authorization Server
  5. The bridge performs ATProto OAuth2 (PAR + PKCE + DPoP) with Bluesky and requests the account:email and rpc:app.bsky.actor.getProfile?aud=did:web:api.bsky.app#bsky_appview scopes
  6. User authenticates on Bluesky's authorization page and approves email + profile access
  7. Bluesky redirects back to this bridge with a code
  8. The bridge exchanges the code for tokens, then calls com.atproto.server.getSession on the user's PDS (DPoP-bound) to retrieve the user's confirmed email
  9. The bridge generates its own OIDC tokens (ID token + UserInfo include email and email_verified) and redirects back to Forgejo
  10. Forgejo creates/logs in the user account using their real Bluesky email

Requirements

  • Java 17+
  • HTTPS — ATProto OAuth requires the client_id URL to be served over HTTPS
  • A publicly accessible domain for this bridge (e.g., oauth-bridge.example.com)

Configuration

Configuration is in src/main/resources/application.conf and can be overridden with environment variables:

Variable Description Default
PUBLIC_URL Public HTTPS URL of this bridge https://oauth-bridge.example.com
PORT Port to listen on 3000
HOST Host to bind to 0.0.0.0
CLIENT_NAME App name shown on Bluesky auth page Forgejo Bluesky Login
CLIENT_URI App URL shown on Bluesky auth page Same as PUBLIC_URL
FORGEJO_URL Your Forgejo instance URL https://forgejo.example.com
ATPROTO_SCOPE ATProto OAuth scopes. Must include account:email (removing it breaks login) and rpc:app.bsky.actor.getProfile?aud=did:web:api.bsky.app#bsky_appview (removing it makes the bridge fall back to the handle for name and the identicon for picture). transition:* scopes are deprecated and dropped at startup with a warning. atproto account:email rpc:app.bsky.actor.getProfile?aud=did:web:api.bsky.app#bsky_appview
INCLUDE_AVATAR When true, the bridge forwards the user's Bluesky profile picture to Forgejo as the OIDC picture claim (see Avatars). Set to false to omit the picture claim. The bridge always calls app.bsky.actor.getProfile regardless of this toggle, because the display name is needed for the name claim — this flag now only controls whether the picture claim is included. true

Database Configuration

The bridge supports persistent session storage via SQLite, PostgreSQL, MySQL, or MariaDB:

Variable Description Default
DATABASE_TYPE Database type: sqlite, postgresql, mysql, mariadb sqlite
DATABASE_PATH Path to SQLite database file oauth-bridge.db
DATABASE_HOST Database host localhost
DATABASE_PORT Database port 5432
DATABASE_NAME Database name oauth_bridge
DATABASE_USER Database username ``
DATABASE_PASSWORD Database password ``

Running

Generate a config file

On first run, generate a config.yaml in the current directory:

# Via Gradle
./gradlew run --args="--generate-config"

# Or via the fat JAR
java -jar intermediate-oauth-1.0-SNAPSHOT-all.jar --generate-config

This writes a config.yaml with default values and exits. Edit it to match your deployment.

Start the server

If a config.yaml exists in the current directory it is loaded automatically. Otherwise the app falls back to application.conf / environment variables.

# Set required environment variables (if not using config.yaml)
export PUBLIC_URL=https://oauth-bridge.yourdomain.com
export FORGEJO_URL=https://forgejo.yourdomain.com

# Run with Gradle
./gradlew run

The server starts on port 3000 by default.

Setting Up Forgejo

  1. Log in to Forgejo as an administrator
  2. Go to Site Administration → Identity & Access → Authentication Sources
  3. Click Add Authentication Source
  4. Configure:
    • Authentication Type: OAuth2
    • Authentication Name: Bluesky (or whatever you prefer)
    • OAuth2 Provider: OpenID Connect
    • Client ID: Any string (e.g., forgejo-bluesky)
    • Client Secret: Any string (e.g., not-used-but-required)
    • OpenID Connect Auto Discovery URL: https://oauth-bridge.yourdomain.com/.well-known/openid-configuration
    • Enable Auto Registration: (recommended)
    • Username Source: nickname
  5. Save

Endpoints

For Forgejo (OIDC Provider)

Endpoint URL
OIDC Discovery /.well-known/openid-configuration
Authorization /authorize
Token /token
UserInfo /userinfo
JWKS /jwks

For Bluesky (ATProto Client)

Endpoint URL
Client Metadata /oauth-client-metadata.json
OAuth Callback /atproto/callback

Utility

Endpoint URL
Health Check /health

ATProto OAuth Compliance

This bridge follows the ATProto OAuth Specification:

  • PAR (Pushed Authorization Requests): Mandatory for initiating flows.
  • PKCE (Proof Key for Code Exchange): Uses S256 for secure authorization codes.
  • DPoP (Demonstrating Proof-of-Possession): Required for all token exchanges and authenticated requests.
  • client_id URL: Must be a valid HTTPS URL pointing to the JSON metadata (e.g., https://oauth-bridge.example.com/oauth-client-metadata.json).
    • Port Restriction: The client_id URL MUST NOT include a port number (except for localhost during development). Use a reverse proxy on port 443.
  • application_type: native: The bridge identifies as a native application. This is a common pattern for bridges and CLI tools to avoid browser sec-fetch-site: same-site restrictions when the bridge and the PDS are on the same domain or site.
  • Intermediate Redirect: Uses a "clean" intermediate page with Referrer-Policy: no-referrer to ensure smooth navigation in all browsers.

Architecture

  • Ktor server framework (Netty engine)
  • ATProto OAuth2 client with PAR, PKCE (S256), and DPoP support
  • OIDC provider with ES256-signed JWTs (ECDSA P-256)
  • Database-backed session storage with SQLite (default), PostgreSQL, MySQL, and MariaDB support via Exposed ORM and HikariCP

OIDC Claims Forwarded to Forgejo

The bridge populates the following standard OIDC claims in both the ID token and the /userinfo response so that Forgejo can auto-register users with a properly populated profile:

Claim Source Notes
sub Bluesky DID (e.g. did:plc:...) Stable subject identifier; never changes.
preferred_username Bluesky handle (e.g. alice.bsky.social) Used by Forgejo as the username.
name Bluesky displayName (falls back to handle) Populates Forgejo's "Full Name" field. Falls back to the handle when the display name is missing or blank, or when app.bsky.actor.getProfile fails.
profile https://bsky.app/profile/<handle> URL of the user's Bluesky profile page. Forgejo can store this as the user's website.
email Confirmed Bluesky email Fetched via com.atproto.server.getSession (see Email Handling).
email_verified emailConfirmed from the PDS session Reflects the actual Bluesky emailConfirmed flag rather than a hard-coded true.
picture Bluesky avatar URL (or identicon fallback) Only forwarded when INCLUDE_AVATAR=true (see Avatars).

These are advertised in /.well-known/openid-configuration under claims_supported and covered by the standard openid profile email scopes.

Email Handling

The bridge requests the account:email scope so it can fetch the user's verified Bluesky email and pass it to Forgejo via the OIDC ID token and /userinfo endpoint. After the OAuth token exchange, the bridge performs a DPoP-authenticated GET com.atproto.server.getSession against the user's PDS to read email and emailConfirmed. The Bluesky access token is used only for this single call and is then discarded — it is never persisted.

The bridge fails the login in any of the following cases (no auth code is ever sent to Forgejo, and the user is shown a styled error page):

  • The user denied email access on Bluesky's consent screen, so the granted scope returned to the bridge does not include account:email.
  • The user's Bluesky email is not confirmed (emailConfirmed != true) — they must verify their email in Bluesky and try again.
  • The PDS getSession call fails (network error, DPoP error, non-2xx response).

This behavior ensures Forgejo is never auto-registered with a placeholder or unverified email.

Avatars

The bridge always calls app.bsky.actor.getProfile against the user's PDS — DPoP-authenticated and proxied to the Bluesky AppView via the Atproto-Proxy: did:web:api.bsky.app#bsky_appview header — because the user's Bluesky displayName is needed unconditionally for the OIDC name claim. From the same response the bridge also reads the avatar URL.

This call requires the rpc:app.bsky.actor.getProfile?aud=did:web:api.bsky.app#bsky_appview scope to be granted by Bluesky. The default ATPROTO_SCOPE already includes it, so new deployments work out of the box. If you customised ATPROTO_SCOPE (or have a config.yaml from before this default existed), make sure to add this token to the scope string — without it Bluesky returns 403 ScopeMissingError for getProfile, and the bridge falls back to the handle for name and the identicon for picture.

When INCLUDE_AVATAR is true (the default), the avatar URL is forwarded to Forgejo as the standard OIDC picture claim in both the ID token and the /userinfo response. If the user has no avatar set on Bluesky, or the profile lookup fails for any reason (network error, DPoP error, non-2xx response, etc.), the bridge logs a warning and falls back to a deterministic Gravatar identicon URL (https://www.gravatar.com/avatar/<sha256(did) hex prefix>?d=identicon&f=y&s=256). The fallback is keyed off the user's DID, so the same user gets the same identicon across logins. Login is never failed for avatar (or display name) issues — when getProfile fails the name claim falls back to the handle and the picture claim falls back to the identicon (or is omitted entirely if INCLUDE_AVATAR=false).

If INCLUDE_AVATAR is set to false, the picture claim is omitted entirely from both the ID token and the /userinfo response; Forgejo will keep its existing avatar logic for that user. The getProfile call is still made because the display name is needed regardless.

To make Forgejo apply the forwarded avatar to the user's account, set the following in Forgejo's app.ini:

[oauth2_client]
UPDATE_AVATAR = true

With this enabled, Forgejo will refresh the user's avatar from the OIDC picture claim on every login.

Security Notes

  • This bridge must be served over HTTPS in production
  • EC (P-256) keys are generated in-memory on startup (tokens become invalid on restart)
  • Session data is persisted in a database (SQLite by default) to survive restarts and enable horizontal scaling
  • The Bluesky-issued OAuth access token is held only in-memory for the duration of the callback handler (used for the getSession call and the subsequent getProfile call) and never logged or persisted

License

MIT