Skip to content

sources/oauth: add AT Protocol source#22044

Open
dominic-r wants to merge 1 commit intomainfrom
sdko/atproto
Open

sources/oauth: add AT Protocol source#22044
dominic-r wants to merge 1 commit intomainfrom
sdko/atproto

Conversation

@dominic-r
Copy link
Copy Markdown
Member

@dominic-r dominic-r commented May 4, 2026

Closes #22031

Cleanedup-ish local dev test server

#!/usr/bin/env python3
"""Small local AT Protocol OAuth/PDS simulator for authentik manual testing."""

from __future__ import annotations

import json
import secrets
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urlencode, urlparse

HOST = "localhost"
PORT = 8787
ISSUER = f"http://{HOST}:{PORT}"
DID = f"did:web:{HOST}%3A{PORT}"
HANDLE = "dominic.localhost"
EMAIL = "dominic@svc.sdko.net"
PASSWORD = "atproto-local-123"

REQUESTS: dict[str, dict[str, str]] = {}
CODES: dict[str, dict[str, str]] = {}


def response_json(handler: BaseHTTPRequestHandler, payload: dict, status: int = 200) -> None:
    body = json.dumps(payload).encode()
    handler.send_response(status)
    handler.send_header("Content-Type", "application/json")
    handler.send_header("Content-Length", str(len(body)))
    handler.send_header("DPoP-Nonce", secrets.token_urlsafe(18))
    handler.end_headers()
    handler.wfile.write(body)


def response_html(handler: BaseHTTPRequestHandler, body: str, status: int = 200) -> None:
    encoded = body.encode()
    handler.send_response(status)
    handler.send_header("Content-Type", "text/html; charset=utf-8")
    handler.send_header("Content-Length", str(len(encoded)))
    handler.end_headers()
    handler.wfile.write(encoded)


def read_form(handler: BaseHTTPRequestHandler) -> dict[str, str]:
    length = int(handler.headers.get("Content-Length", "0"))
    raw = handler.rfile.read(length).decode()
    return {key: values[-1] for key, values in parse_qs(raw).items()}


class Handler(BaseHTTPRequestHandler):
    server_version = "authentik-local-atproto/0.1"

    def log_message(self, fmt: str, *args) -> None:
        print(f"{self.address_string()} - {fmt % args}")

    def do_GET(self) -> None:
        parsed = urlparse(self.path)
        query = parse_qs(parsed.query)

        if parsed.path == "/client-metadata.json":
            response_json(
                self,
                {
                    "client_id": f"{ISSUER}/client-metadata.json",
                    "client_name": "authentik local AT Protocol test",
                    "redirect_uris": ["http://localhost:9000/source/oauth/callback/atproto-local/"],
                    "grant_types": ["authorization_code", "refresh_token"],
                    "response_types": ["code"],
                    "scope": "atproto transition:email",
                    "token_endpoint_auth_method": "none",
                    "application_type": "web",
                    "dpop_bound_access_tokens": True,
                },
            )
            return

        if parsed.path == "/.well-known/did.json":
            response_json(
                self,
                {
                    "@context": ["https://www.w3.org/ns/did/v1"],
                    "id": DID,
                    "service": [
                        {
                            "id": "#atproto_pds",
                            "type": "AtprotoPersonalDataServer",
                            "serviceEndpoint": ISSUER,
                        }
                    ],
                },
            )
            return

        if parsed.path == "/.well-known/oauth-protected-resource":
            response_json(self, {"authorization_servers": [ISSUER]})
            return

        if parsed.path == "/xrpc/com.atproto.identity.resolveHandle":
            response_json(self, {"did": DID, "handle": query.get("handle", [HANDLE])[-1]})
            return

        if parsed.path == "/xrpc/app.bsky.actor.getProfile":
            response_json(
                self,
                {
                    "did": DID,
                    "handle": HANDLE,
                    "displayName": "Dominic Local",
                    "description": "Local AT Protocol OAuth test user",
                },
            )
            return

        if parsed.path == "/xrpc/com.atproto.server.getSession":
            response_json(
                self,
                {
                    "did": DID,
                    "handle": HANDLE,
                    "email": EMAIL,
                    "emailConfirmed": True,
                },
            )
            return

        if parsed.path == "/oauth/authorize":
            request_uri = query.get("request_uri", [""])[-1]
            par = REQUESTS.get(request_uri)
            if not par:
                response_html(self, "<h1>Unknown request_uri</h1>", 400)
                return
            response_html(
                self,
                f"""
                <!doctype html>
                <title>Local AT Protocol Login</title>
                <main style="font: 16px system-ui; max-width: 560px; margin: 64px auto">
                  <h1>Local AT Protocol Login</h1>
                  <p>Sign in as <strong>{HANDLE}</strong> ({DID}).</p>
                  <form method="post" action="/oauth/approve">
                    <input type="hidden" name="request_uri" value="{request_uri}">
                    <label style="display:block;margin:12px 0">
                      Email
                      <input name="email" type="email" value="{EMAIL}" required
                        style="display:block;font:inherit;padding:8px;width:100%;box-sizing:border-box">
                    </label>
                    <label style="display:block;margin:12px 0">
                      Password
                      <input name="password" type="password" required
                        style="display:block;font:inherit;padding:8px;width:100%;box-sizing:border-box">
                    </label>
                    <p>Local test password: <code>{PASSWORD}</code></p>
                    <button style="font: inherit; padding: 10px 14px">Approve authentik</button>
                  </form>
                </main>
                """,
            )
            return

        response_json(self, {"error": "not_found", "path": parsed.path}, 404)

    def do_POST(self) -> None:
        parsed = urlparse(self.path)

        if parsed.path == "/oauth/par":
            if "DPoP" not in self.headers:
                response_json(self, {"error": "use_dpop_nonce"}, 400)
                return
            data = read_form(self)
            request_uri = f"urn:ietf:params:oauth:request_uri:{secrets.token_urlsafe(24)}"
            REQUESTS[request_uri] = data
            response_json(self, {"request_uri": request_uri, "expires_in": 300})
            return

        if parsed.path == "/oauth/approve":
            data = read_form(self)
            identifier = data.get("email")
            password = data.get("password")
            if identifier not in {EMAIL, HANDLE} or password != PASSWORD:
                response_html(
                    self,
                    f"""
                    <!doctype html>
                    <title>Local AT Protocol Login</title>
                    <main style="font: 16px system-ui; max-width: 560px; margin: 64px auto">
                      <h1>Invalid credentials</h1>
                      <p>Use <code>{EMAIL}</code> and <code>{PASSWORD}</code>.</p>
                      <p><a href="/oauth/authorize?request_uri={data.get('request_uri', '')}">Try again</a></p>
                    </main>
                    """,
                    401,
                )
                return
            par = REQUESTS[data["request_uri"]]
            code = secrets.token_urlsafe(24)
            CODES[code] = par
            params = urlencode({"code": code, "state": par["state"], "iss": ISSUER})
            redirect_uri = f"{par['redirect_uri']}?{params}"
            self.send_response(302)
            self.send_header("Location", redirect_uri)
            self.end_headers()
            return

        if parsed.path == "/oauth/token":
            if "DPoP" not in self.headers:
                response_json(self, {"error": "use_dpop_nonce"}, 400)
                return
            data = read_form(self)
            if data.get("code") not in CODES:
                response_json(self, {"error": "invalid_grant"}, 400)
                return
            response_json(
                self,
                {
                    "access_token": secrets.token_urlsafe(32),
                    "refresh_token": secrets.token_urlsafe(32),
                    "token_type": "DPoP",
                    "expires_in": 3600,
                    "scope": CODES[data["code"]].get("scope", "atproto"),
                    "sub": DID,
                },
            )
            return

        response_json(self, {"error": "not_found", "path": parsed.path}, 404)


if __name__ == "__main__":
    print(f"Local AT Protocol OAuth/PDS simulator: {ISSUER}")
    print(f"Test account: handle={HANDLE} did={DID} email={EMAIL}")
    ThreadingHTTPServer((HOST, PORT), Handler).serve_forever()

Details

@dominic-r dominic-r requested review from a team as code owners May 4, 2026 23:48
@dominic-r dominic-r self-assigned this May 4, 2026
@dominic-r dominic-r added area:frontend Features or issues related to the browser, TypeScript, Node.js, etc area:backend area:docs Features or issues related to Docusaurus labels May 4, 2026
@netlify
Copy link
Copy Markdown

netlify Bot commented May 4, 2026

Deploy Preview for authentik-storybook ready!

Name Link
🔨 Latest commit 18d4f85
🔍 Latest deploy log https://app.netlify.com/projects/authentik-storybook/deploys/69f9304bcad8920008a1f410
😎 Deploy Preview https://deploy-preview-22044--authentik-storybook.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@netlify
Copy link
Copy Markdown

netlify Bot commented May 4, 2026

Deploy Preview for authentik-integrations ready!

Name Link
🔨 Latest commit 18d4f85
🔍 Latest deploy log https://app.netlify.com/projects/authentik-integrations/deploys/69f9304bd47fe80008a90430
😎 Deploy Preview https://deploy-preview-22044--authentik-integrations.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

def icon_url(self) -> str:
return static("authentik/sources/atproto.svg")

def get_base_user_properties(self, info: dict[str, Any], **kwargs) -> dict[str, Any]:
@netlify
Copy link
Copy Markdown

netlify Bot commented May 4, 2026

Deploy Preview for authentik-docs ready!

Name Link
🔨 Latest commit 18d4f85
🔍 Latest deploy log https://app.netlify.com/projects/authentik-docs/deploys/69f9304b8f8b7b00080388a5
😎 Deploy Preview https://deploy-preview-22044--authentik-docs.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@codecov
Copy link
Copy Markdown

codecov Bot commented May 4, 2026

Codecov Report

❌ Patch coverage is 82.09302% with 77 lines in your changes missing coverage. Please review.
✅ Project coverage is 93.03%. Comparing base (d69433b) to head (18d4f85).
⚠️ Report is 1 commits behind head on main.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
authentik/sources/oauth/types/atproto.py 74.23% 76 Missing ⚠️
authentik/sources/oauth/api/source.py 80.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #22044      +/-   ##
==========================================
- Coverage   93.17%   93.03%   -0.15%     
==========================================
  Files        1024     1026       +2     
  Lines       59278    59708     +430     
  Branches      400      400              
==========================================
+ Hits        55234    55551     +317     
- Misses       4044     4157     +113     
Flag Coverage Δ
conformance 36.77% <19.06%> (-0.13%) ⬇️
e2e 41.92% <19.06%> (-0.17%) ⬇️
integration 32.70% <19.06%> (-0.60%) ⬇️
rust 0.00% <ø> (ø)
unit 92.02% <82.09%> (-0.08%) ⬇️
unit-migrate 92.03% <82.09%> (-0.09%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 5, 2026

authentik PR Installation instructions

Instructions for docker-compose

Add the following block to your .env file:

AUTHENTIK_IMAGE=ghcr.io/goauthentik/dev-server
AUTHENTIK_TAG=gh-18d4f8557911bb62533cc25bbd7b2db2d5fbf17d
AUTHENTIK_OUTPOSTS__CONTAINER_IMAGE_BASE=ghcr.io/goauthentik/dev-%(type)s:gh-%(build_hash)s

Afterwards, run the upgrade commands from the latest release notes.

Instructions for Kubernetes

Add the following block to your values.yml file:

authentik:
    outposts:
        container_image_base: ghcr.io/goauthentik/dev-%(type)s:gh-%(build_hash)s
global:
    image:
        repository: ghcr.io/goauthentik/dev-server
        tag: gh-18d4f8557911bb62533cc25bbd7b2db2d5fbf17d

Afterwards, run the upgrade commands from the latest release notes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:backend area:docs Features or issues related to Docusaurus area:frontend Features or issues related to the browser, TypeScript, Node.js, etc

Projects

Status: Todo

Development

Successfully merging this pull request may close these issues.

Add Bluesky/atproto source

1 participant