ATProto-to-OIDC login bridge. Fork of anirudh.fi/atlogin, based on apenwarr/atlogin.
Go 78%
Python 9%
JavaScript 8%
Nix 4%
Shell <1%
Dockerfile <1%

README.md

atlogin #

This fork builds on anirudh.fi/atlogin, which builds on apenwarr/atlogin. See CHANGES.md for authentication changes and compatibility notes.

OIDC identity provider for AT Protocol handles. Lets users log in to any OIDC-compatible app using their AT Protocol identity.

how it works #

Users can enter a plain AT Protocol handle on the authorization page. Existing email-style login hints are also accepted and map to a handle as follows:

Login Handle
alice@example.com @alice.example.com
alice@alice.bsky.social @alice.bsky.social (prefix match)
alice.bsky.social@your-instance.example.com @alice.bsky.social (passthrough)

The server's own hostname is a passthrough domain — the username part is used directly as the AT Protocol handle.

Profile suggestions use the public Bluesky AppView; lookup requests include the entered query and recent handles. Recent handles are stored in the browser.

deployment #

files #

atlogin reads config.json and signing-key.json from its state directory (default: ./state). Initialize it once:

atlogin -init -state-dir ./state

Edit ./state/config.json:

{
  "addr": "127.0.0.1:9411",
  "issuer": "https://your-instance.example.com",
  "client_name": "your instance name",
  "master_key": "<hex string, generate with: openssl rand -hex 32>",
  "secrets": {
    "your-client-id": "your-client-secret"
  },
  "redirect_uris": {
    "your-client-id": ["https://yourapp.example.com/callback"]
  }
}

Replace the client secret with a strong random value (at least 32 bytes), then start the server:

atlogin -state-dir ./state

Keep the directory private and retain the signing key across restarts. Put an HTTPS reverse proxy in front of the loopback listener.

env vars #

For deployments that inject secrets through environment variables:

  • ATLOGIN_CONFIG: base64-encoded contents of config.json
  • ATLOGIN_SIGNING_KEY: base64-encoded contents of signing-key.json
export ATLOGIN_CONFIG="$(base64 < ./state/config.json | tr -d '\n')"
export ATLOGIN_SIGNING_KEY="$(base64 < ./state/signing-key.json | tr -d '\n')"
atlogin -state-dir ./state

Each variable is optional. When set, it overwrites its corresponding state file at startup; unset it to use the file directly. Base64 is encoding, not encryption.

client credentials #

To provision a client (e.g. Tailscale), generate the client ID and secret using gen-client.sh:

./gen-client.sh <login-email> <app-name> <master-key>
# e.g.:
./gen-client.sh alice@example.com Tailscale abc123...
# Client ID:     alice-at-example-com-Tailscale-v1
# Client Secret: <deterministic base64>

Add the output to the secrets (and mandatory redirect_uris) in your config.json (or its ATLOGIN_CONFIG copy).

The client secret is deterministic: base64(HMAC-SHA256(client_id, master_key)). Running the script again with the same inputs produces the same secret.

flags #

-state-dir <path>   State directory for signing key and config (default: ./state)
-init               Initialize state directory and exit
-new-client <id>    Add a new client to config.json and print the secret

webfinger #

atlogin serves WebFinger for its own hostname automatically. For users to log in as user@otherdomain.com, otherdomain.com needs to proxy /.well-known/webfinger to your atlogin instance's /helpers/webfinger:

location /.well-known/webfinger {
    proxy_pass https://your-instance.example.com/helpers/webfinger;
}

This also works with a Cloudflare Worker or any static redirect.

NixOS #

A NixOS module is included in the flake:

{
  inputs.atlogin.url = "git+https://tangled.org/kira.ws/atlogin";

  # in your NixOS config:
  imports = [ atlogin.nixosModules.default ];

  services.atlogin = {
    enable = true;
    settings = null;  # use /var/lib/atlogin/config.json directly
    # Or inject the base64 variables from a private file:
    # environmentFile = "/etc/secrets/atlogin.env";
  };
}

The service user must be able to read the state files. Avoid putting client secrets in services.atlogin.settings: they would enter the Nix store.

OIDC endpoints #

Endpoint Description
/.well-known/openid-configuration OIDC discovery
/.well-known/jwks.json Public keys
/.well-known/webfinger WebFinger
/helpers/webfinger WebFinger helper for reverse proxying
/authorize Authorization
/token Token exchange
/userinfo User info

remembered sign-in #

Remember me on this device creates a Secure, HttpOnly cookie backed by a private remembered-sessions.json file. Sessions survive restarts and expire without sliding renewal after 30 days. Set remember_days to 0 to disable, or 1–90 to choose a lifetime. The file stores token hashes, not PDS credentials.

switch_account=1, prompt=login and max_age can force a new login flow. prompt=select_account normally shows the picker; set remember_select_account: true when your clients send that prompt routinely and you want the saved-account countdown instead. Silent prompt=none requests do not display a countdown.

custom CSS #

Set custom_css_file to a local stylesheet path. It is loaded at startup and served after the default styles on the login and remembered-account screens:

{"custom_css_file": "/var/lib/atlogin/theme.css"}

The service user must be able to read it. Missing or oversized files fail startup; the limit is 8 MiB. Same-origin font files and data-URL fonts are supported. Restart the service to load stylesheet changes. No theme is required by default.

relying-party setup #

Register an exact HTTPS callback URL, a fixed HTTPS issuer origin, and a client secret of at least 32 bytes. Use the openid profile scopes; S256 PKCE is supported.

sub is the DID. name and preferred_username are the handle.

Signing keys and remembered sessions persist across restarts. In-flight logins and access tokens do not.