- Kotlin 100%
|
All checks were successful
Build Shadow JAR / build (push) Successful in 1m32s
- 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. |
||
|---|---|---|
| .forgejo/workflows | ||
| .idea | ||
| gradle/wrapper | ||
| src | ||
| .gitignore | ||
| build.gradle.kts | ||
| gradle.properties | ||
| gradlew | ||
| gradlew.bat | ||
| README.md | ||
| settings.gradle.kts | ||
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
- User clicks "Sign in with Bluesky" on Forgejo
- Forgejo redirects to this bridge's
/authorizeendpoint - User enters their Bluesky handle (e.g.,
alice.bsky.social) - The bridge resolves the handle → DID → PDS → Authorization Server
- The bridge performs ATProto OAuth2 (PAR + PKCE + DPoP) with Bluesky and requests the
account:emailandrpc:app.bsky.actor.getProfile?aud=did:web:api.bsky.app#bsky_appviewscopes - User authenticates on Bluesky's authorization page and approves email + profile access
- Bluesky redirects back to this bridge with a code
- The bridge exchanges the code for tokens, then calls
com.atproto.server.getSessionon the user's PDS (DPoP-bound) to retrieve the user's confirmed email - The bridge generates its own OIDC tokens (ID token + UserInfo include
emailandemail_verified) and redirects back to Forgejo - Forgejo creates/logs in the user account using their real Bluesky email
Requirements
- Java 17+
- HTTPS — ATProto OAuth requires the
client_idURL 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
- Log in to Forgejo as an administrator
- Go to Site Administration → Identity & Access → Authentication Sources
- Click Add Authentication Source
- 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
- 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
S256for secure authorization codes. - DPoP (Demonstrating Proof-of-Possession): Required for all token exchanges and authenticated requests.
client_idURL: 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_idURL MUST NOT include a port number (except forlocalhostduring development). Use a reverse proxy on port 443.
- Port Restriction: The
application_type: native: The bridge identifies as anativeapplication. This is a common pattern for bridges and CLI tools to avoid browsersec-fetch-site: same-siterestrictions when the bridge and the PDS are on the same domain or site.- Intermediate Redirect: Uses a "clean" intermediate page with
Referrer-Policy: no-referrerto 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
getSessioncall 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
getSessioncall and the subsequentgetProfilecall) and never logged or persisted
License
MIT