Help & integration guide

How to sign in, use OAuth, call the API, and manage sessions on this account service.

Home

Overview

Central login, OAuth2-style authorization code flow, bearer-token API, email verification, and password reset for connected sites.

Base URL
https://auth.timfalken.com
Session lifetime
3600 seconds (1 hour)
Web cookie
account_session (HttpOnly, SameSite=Lax)

Sign in & accounts

Register

Visit /register. The first account becomes admin. Usernames are stored in lowercase; the form converts capitals as you type. Passwords must be at least 8 characters. A verification email is sent when SMTP is configured.

Sign in

Visit /login and submit your username or email and password in one field. Matching is case-insensitive for both username and email. After you continue, passkey sign-in is offered when your account has registered passkeys. Legacy usernames with capital letters are converted to lowercase on sign-in. On success you receive the account_session cookie and are redirected to / or a safe return_to path (must start with /).

Passkeys

Signed-in users manage passkeys at /account?tab=passkeys. You can register this device, rename or remove passkeys, and optionally enable passkey-only login (password sign-in is then blocked until you disable the setting with a successful passkey verification).

Sign out

POST to /logout with a CSRF token. This clears the cookie and revokes the server session.

OAuth for connected sites

This service implements an OAuth2-style authorization code flow. Each site is registered by domain name; the domain is the client_id.

1. Register your site (admin)

An admin adds your domain and callback paths at /admin, e.g. domain timfalken.com with path /auth/callback. Copy the client_secret immediately — it is only shown once.

2. Redirect the user to authorize

GET https://auth.timfalken.com/oauth/authorize
  ?client_id=timfalken.com
  &redirect_uri=https://blog.timfalken.com/auth/callback
  &response_type=code
  &state=RANDOM_CSRF_TOKEN
  • state is required — verify it matches on callback.
  • Query strings on redirect_uri are stripped; register the path only.
  • Subdomains of the registered domain are allowed.
  • Unauthenticated users are sent to /login and returned here after sign-in.

3. Exchange the code (server-side)

In your callback handler (e.g. /auth/callback), exchange the code on the server. Use the same redirect_uri as in step 2.

POST https://auth.timfalken.com/oauth/token
Content-Type: application/x-www-form-urlencoded
Accept: application/json
User-Agent: MySiteAccountClient/1.0

grant_type=authorization_code
&code=AUTHORIZATION_CODE
&redirect_uri=https://blog.timfalken.com/auth/callback
&client_id=timfalken.com
&client_secret=YOUR_SECRET

Authorization codes expire in 5 minutes and are single-use. After approval, the browser is redirected to your callback:

302 Location: https://blog.timfalken.com/auth/callback?code=AUTHORIZATION_CODE&state=CLIENT_STATE

Successful token exchange response:

{
  "access_token": "a1b2c3d4e5f6789012345678abcdef01",
  "token_type": "Bearer",
  "expires_in": 3600,
  "user": {
    "id": 1,
    "username": "tim",
    "email": "tim@example.com"
  }
}

Error responses use {"error":"..."} with HTTP 400, for example {"error":"Invalid client credentials."}.

Connected site requirements

Every site that integrates with this account service must implement the following for its own users.

User data export (required)

Provide a clearly labelled control in user settings — for example Export my data or Download my data (JSON). When the signed-in user clicks it, they must immediately receive a complete JSON export of all personal data your site stores for them.

  • Scope: profile/auth fields (from GET /api/me or your stored OAuth user), preferences, and any user-created content in your database.
  • Format: application/json.
  • Delivery: show the raw JSON in a modal, or trigger a .json file download (e.g. my-data-export.json).
  • Access: only the authenticated user may export their own data — no email request, no admin approval.
  • Implementation: add a server-side route on your site (e.g. GET /settings/export) that aggregates and returns the JSON on click.
{
  "exported_at": "2026-06-05T12:00:00Z",
  "account": {
    "id": 1,
    "username": "tim",
    "email": "tim@example.com",
    "email_verified": true
  },
  "preferences": { "...": "..." },
  "content": [ "... user-owned records ..." ]
}

This export covers data on your connected site. The account service itself does not store site-specific content beyond authentication.

Server-to-server HTTP client

Connected sites call /oauth/token and /api/* from PHP or another backend HTTP client. Send these headers on every account request:

  • User-Agent: MySiteAccountClient/1.0 — identify your integration (site name + version).
  • Accept: application/json — receive JSON success and error payloads.
  • Content-Type: application/x-www-form-urlencoded — for POST /oauth/token.
  • Authorization: Bearer <access_token> — for /api/me, /api/session/extend, and /api/logout.

Store the access_token in your site session after a successful token exchange. Parse the JSON body in your callback handler and map error fields to your login error UI.

API endpoints

All API routes require Authorization: Bearer <access_token> obtained from the token exchange.

GET /api/me

Validate the token and return the current user.

GET https://auth.timfalken.com/api/me
Authorization: Bearer ACCESS_TOKEN
Accept: application/json
User-Agent: MySiteAccountClient/1.0

POST /api/session/extend

Reset the session expiry to another full hour. Call this during active use before the token expires.

POST https://auth.timfalken.com/api/session/extend
Authorization: Bearer ACCESS_TOKEN
Accept: application/json
User-Agent: MySiteAccountClient/1.0
{
  "expires_at": "2026-06-05 19:00:00",
  "expires_in": 3600
}

POST /api/logout

Revoke the bearer token.

{"ok": true}

GET /api/me — response body

{
  "user": {
    "id": 1,
    "username": "tim",
    "email": "tim@example.com",
    "is_admin": false,
    "email_verified": true
  }
}

The token exchange user object contains only id, username, and email. /api/me adds is_admin and email_verified.

Response examples

Every route below lists the status code, content type, and example payload or redirect target returned by this service.

Conventions

  • JSON errors use {"error":"message"} with application/json; charset=utf-8.
  • Browser form flows return 302 redirects with flash messages in query params (message, error).
  • OAuth token and /api/* routes return JSON bodies on success and failure.

GET /

Home page; shows sign-in state and links.

200 text/html — Home page with sign-in links or signed-in state.

GET /help

Human-readable integration and usage guide.

200 text/html — Help page.

302 redirect — When Accept prefers application/json.

Location: https://auth.timfalken.com/help.json

GET /help.json

Machine-readable JSON specification of all auth functionality.

200 application/json

{
    "schema_version": "1.1",
    "service": "account-auth",
    "endpoints": "[...]"
}

GET /login

Sign-in form. Supports return_to query param (relative path only).

200 text/html — Login form. Query params: error, message, return_to.

POST /login

302 redirect — Success; session cookie set.

Location: /

Sets cookie account_session.

302 redirect — Success with safe return_to path.

Location: /oauth/authorize?...

302 redirect — Invalid credentials.

Location: /login?error=Invalid+username%2Femail+or+password.

302 redirect — Invalid CSRF token.

Location: /login?error=Invalid+session.+Please+try+again.

GET /register

Registration form.

200 text/html — Registration form.

POST /register

302 redirect — First user (admin) created.

Location: /admin?tab=domains&message=Account+created.+Check+your+email...

Sets cookie account_session.

302 redirect — Subsequent user created.

Location: /?message=Account+created.+Check+your+email...

Sets cookie account_session.

302 redirect — Validation error.

Location: /register?error=Password+must+be+at+least+8+characters.

POST /logout

302 redirect — Cookie cleared and session revoked.

Location: /login?message=Signed+out.

GET /verify-email

Email verification link handler.

302 redirect — Valid token with return_to while still signed in.

Location: /oauth/authorize?client_id=...

302 redirect — Valid token with return_to after sign-out.

Location: /login?return_to=%2Foauth%2Fauthorize%3F...&message=Email+verified.+You+can+now+continue.

302 redirect — Valid token without return_to.

Location: /login?message=Email+verified.+You+can+now+continue.

302 redirect — Invalid or expired token.

Location: /login?error=Verification+link+is+invalid+or+has+expired.

GET /forgot-password

Request password reset form.

200 text/html — Forgot-password form.

POST /forgot-password

Always shows generic success message (no email enumeration).

302 redirect — Always returned when request is accepted.

Location: /forgot-password?message=If+an+account+with+a+verified+email+matches%2C+a+reset+link+has+been+sent.

GET /reset-password

Password reset form when token is valid.

200 text/html — Reset form with hidden token field.

302 redirect — Invalid token on GET.

Location: /login?error=Password+reset+link+is+invalid+or+has+expired.

POST /reset-password

302 redirect — Password updated; all user sessions revoked.

Location: /login?message=Password+updated.+Sign+in+with+your+new+password.

302 redirect — Password confirmation mismatch.

Location: /reset-password?token=...&error=Passwords+do+not+match.

POST /resend-verification

Resend verification email for signed-in unverified user.

302 redirect — Email sent.

Location: /?message=Verification+email+sent.

302 redirect — Already verified.

Location: /?message=Your+email+is+already+verified.

GET /oauth/authorize

OAuth authorization; shows consent when authenticated.

200 text/html — Consent screen when user is signed in.

302 redirect — Unauthenticated user sent to login first.

Location: /login?return_to=%2Foauth%2Fauthorize%3F...

400 application/json

{
    "error": "client_id, redirect_uri, and state are required."
}

400 application/json

{
    "error": "Unknown client_id."
}

POST /oauth/authorize

302 redirect — User approved; browser redirect with one-time code.

Location: https://blog.timfalken.com/auth/callback?code=AUTHORIZATION_CODE&state=CLIENT_STATE

200 application/json — User approved; JSON redirect URL when Accept includes application/json.

{
    "redirect": "https://blog.timfalken.com/auth/callback?code=AUTHORIZATION_CODE&state=CLIENT_STATE"
}

400 application/json

{
    "error": "Invalid CSRF token."
}

401 application/json

{
    "error": "Authentication required."
}

POST /oauth/token

200 application/json

{
    "access_token": "a1b2c3d4e5f6789012345678abcdef01",
    "token_type": "Bearer",
    "expires_in": 3600,
    "user": {
        "id": 1,
        "username": "tim",
        "email": "tim@example.com"
    }
}

400 application/json

{
    "error": "Invalid client credentials."
}

400 application/json

{
    "error": "Invalid or expired authorization code."
}

400 application/json

{
    "error": "redirect_uri does not match the authorized value."
}

400 application/json

{
    "error": "Unsupported grant_type."
}

GET /api/me

200 application/json

{
    "user": {
        "id": 1,
        "username": "tim",
        "email": "tim@example.com",
        "is_admin": false,
        "email_verified": true
    }
}

401 application/json

{
    "error": "Bearer token required."
}

401 application/json

{
    "error": "Invalid or expired token."
}

POST /api/session/extend

Sliding session renewal; resets TTL to session_ttl_seconds.

200 application/json

{
    "expires_at": "2026-06-05 19:00:00",
    "expires_in": 3600
}

401 application/json

{
    "error": "Bearer token required."
}

401 application/json

{
    "error": "Invalid or expired token."
}

POST /api/logout

Revokes the bearer token server-side.

200 application/json

{
    "ok": true
}

401 application/json

{
    "error": "Bearer token required."
}

401 application/json

{
    "error": "Invalid or expired token."
}

GET /admin

Admin UI for OAuth domains and SMTP settings.

200 text/html — Admin panel.

302 redirect — Non-admin or guest.

Location: /login?error=Admin+access+required.

POST /admin/domains

302 redirect — Domain added; new client_secret flashed once in session.

Location: /admin?tab=domains&message=Domain+registered.

POST /admin/smtp

Save SMTP settings; blank password keeps existing password.

302 redirect — Settings saved.

Location: /admin?tab=smtp&message=SMTP+settings+saved.

POST /admin/smtp/test

Send test email using form values.

302 redirect — Test email sent.

Location: /admin?tab=smtp&message=Test+email+sent+to+admin%40example.com.

Sessions

  • Web sessions use the account_session cookie (session_type=web). They work for the account site and OAuth consent, not as API bearer tokens.
  • API sessions use bearer tokens (session_type=oauth) from /oauth/token. They are locked to the host that started the OAuth flow.
  • Default TTL is 1 hour for both. Bearer tokens can be extended with POST /api/session/extend.
  • Expired or invalid tokens return 401 on API routes.
  • Password reset revokes all sessions for that user.

Email verification & password reset

  • Verification link: GET /verify-email?token=... (valid 24 hours).
  • Resend while signed in: POST /resend-verification.
  • Forgot password: /forgot-password — only verified emails receive a reset link.
  • Reset link: GET /reset-password?token=... (valid 1 hour).
  • Admins configure SMTP at /admin?tab=smtp and can send a test email.

Admin

Admins manage OAuth domains, callback paths, client secrets, and SMTP at /admin.

  • Register domains with at least one callback path.
  • Regenerate client secrets from the domains tab; old secrets stop working immediately.
  • SMTP: port 587 + TLS (STARTTLS) is typical; use 465 + SSL if your provider requires it.

Machine-readable specification

For scripts, integrations, and AI agents, use the JSON spec (also advertised in the page <head> via rel="alternate" and the Link response header):

https://auth.timfalken.com/help.json

Clients that send Accept: application/json (without preferring HTML) are redirected from /help to /help.json automatically.

The JSON includes all endpoints, flows, constants, authentication modes, and integration checklist. It is the canonical structured reference; this page is the human-readable view.

View embedded JSON spec
{
    "schema_version": "1.2",
    "response_conventions": {
        "json_content_type": "application/json; charset=utf-8",
        "error_envelope": {
            "error": "string"
        },
        "redirect": "HTTP 302 with Location header; no JSON body",
        "html": "HTTP 200 text/html page"
    },
    "service": "account-auth",
    "title": "Account authentication service",
    "description": "Central login, OAuth2-style authorization code flow, bearer-token API, email verification, and password reset for connected sites.",
    "base_url": "https://auth.timfalken.com",
    "documentation_urls": {
        "human": "https://auth.timfalken.com/help",
        "machine": "https://auth.timfalken.com/help.json"
    },
    "discovery": {
        "alternate_link": {
            "rel": "alternate",
            "type": "application/json",
            "href": "/help.json",
            "location": "HTML head on GET /help"
        },
        "link_header": "Link: </help.json>; rel=\"alternate\"; type=\"application/json\"",
        "accept_negotiation": "GET /help redirects to /help.json when Accept prefers application/json over text/html"
    },
    "requirements": {
        "https_required_in_production": true,
        "password_min_length": 8,
        "redirect_uri_scheme": [
            "https"
        ],
        "username_lowercase": true,
        "login_identifier": "Single field accepts username or email; both matched case-insensitively.",
        "passkeys": {
            "supported": true,
            "management_path": "/account?tab=passkeys",
            "passkey_only_default": false
        }
    },
    "integrator_requirements": {
        "user_data_export": {
            "required": true,
            "summary": "Every connected site must let signed-in users immediately download a complete JSON export of all personal data that site stores for them.",
            "ui": {
                "location": "User settings or account preferences page",
                "control": "Clearly labelled button or link, e.g. \"Export my data\" or \"Download my data (JSON)\""
            },
            "scope": [
                "Include all personal data your site stores for the requesting user.",
                "Include account profile fields from GET /api/me or your stored OAuth user record.",
                "Include preferences, activity, and any user-created content held in your database."
            ],
            "format": "application/json",
            "delivery": [
                "Show the raw JSON in a modal or dialog, or",
                "Trigger a file download as .json (Content-Disposition: attachment recommended)."
            ],
            "constraints": [
                "Export must be available immediately on click \u2014 no email request or admin approval.",
                "Only the authenticated user may export their own data."
            ],
            "suggested_route": "GET /settings/export or POST /settings/export-data (implement on your site)",
            "example_filename": "my-data-export.json"
        }
    },
    "constants": {
        "session_ttl_seconds": 3600,
        "oauth_code_ttl_seconds": 300,
        "email_verify_ttl_seconds": 86400,
        "password_reset_ttl_seconds": 3600,
        "web_cookie_name": "account_session",
        "csrf_field": "csrf"
    },
    "authentication": {
        "web_session": {
            "type": "http_only_cookie",
            "cookie_name": "account_session",
            "set_on": [
                "POST /login",
                "POST /register"
            ],
            "cleared_on": [
                "POST /logout"
            ],
            "ttl_seconds": 3600,
            "sliding_expiry": "Cookie max-age resets on login; use POST /api/session/extend for bearer tokens.",
            "notes": [
                "Web sessions use session_type=web. OAuth bearer tokens use session_type=oauth and are not accepted as web cookies.",
                "Cookies are Secure when HTTPS is detected (including X-Forwarded-Proto: https)."
            ]
        },
        "api_bearer": {
            "type": "authorization_header",
            "header": "Authorization: Bearer <access_token>",
            "obtained_via": "POST /oauth/token after authorization code exchange",
            "ttl_seconds": 3600,
            "host_lock": "OAuth tokens are locked to the redirect_uri host that initiated the flow."
        },
        "oauth_client": {
            "client_id": "Registered domain name (e.g. timfalken.com)",
            "client_secret": "Shown once when domain is registered or regenerated in /admin",
            "redirect_uri_rules": [
                "Scheme must be https (http only when ACCOUNT_ALLOW_HTTP=1).",
                "Query strings are stripped during validation; register the path only.",
                "Fragments are rejected.",
                "Host must belong to the registered domain (including subdomains).",
                "Path must be registered as a callback path for that domain."
            ]
        },
        "csrf": {
            "required_on": "All browser POST forms",
            "field_name": "csrf",
            "invalid_handling": "Redirect with error or HTTP 400 for OAuth authorize POST"
        },
        "server_side_http_client": {
            "applies_to": [
                "POST /oauth/token",
                "GET /api/me",
                "POST /api/session/extend",
                "POST /api/logout"
            ],
            "required_headers": {
                "User-Agent": "A descriptive client name and version, e.g. MySiteAccountClient/1.0",
                "Accept": "application/json"
            },
            "token_exchange_headers": {
                "Content-Type": "application/x-www-form-urlencoded",
                "Accept": "application/json",
                "User-Agent": "Your client identifier"
            },
            "api_headers": {
                "Authorization": "Bearer <access_token>",
                "Accept": "application/json",
                "User-Agent": "Your client identifier"
            },
            "response_format": "JSON object; success and error payloads use Content-Type application/json",
            "implementation_notes": [
                "Perform token exchange in your OAuth callback handler on the server.",
                "Use the same redirect_uri value as in the authorize redirect.",
                "Store client_secret only in server-side configuration.",
                "Parse the JSON body; map error fields to your site login error handling."
            ]
        }
    },
    "flows": [
        {
            "id": "web_sign_in",
            "title": "Sign in on the account site",
            "audience": "human",
            "steps": [
                "Visit GET /login.",
                "Submit POST /login with csrf, identifier (username or email, case-insensitive), and password.",
                "Legacy usernames with uppercase letters are migrated to lowercase on successful sign-in.",
                "After entering identifier, clients may offer passkey sign-in when the account has registered passkeys.",
                "Passkey-only accounts reject password login until the setting is disabled at /account?tab=passkeys.",
                "On success, account_session cookie is set and the browser redirects to / or return_to.",
                "Sign out via POST /logout (requires csrf)."
            ]
        },
        {
            "id": "web_register",
            "title": "Create an account",
            "audience": "human",
            "steps": [
                "Visit GET /register.",
                "Submit POST /register with csrf, username (stored lowercase), email, and password (min 8 characters).",
                "The registration form converts uppercase letters to lowercase while typing.",
                "Verification email is sent when SMTP is configured."
            ]
        },
        {
            "id": "email_verification",
            "title": "Verify email address",
            "audience": "human",
            "steps": [
                "User receives email with link to GET /verify-email?token=...&return_to=... when continuing an OAuth sign-in.",
                "Valid token marks email verified and redirects to return_to (or /login?return_to=...).",
                "Signed-in unverified users can POST /resend-verification with the pending return_to preserved.",
                "Password reset only works for verified emails."
            ]
        },
        {
            "id": "password_reset",
            "title": "Reset password",
            "audience": "human",
            "steps": [
                "Visit GET /forgot-password and submit email via POST /forgot-password.",
                "If a verified account matches, email contains GET /reset-password?token=...",
                "Submit new password via POST /reset-password; all sessions for that user are revoked."
            ]
        },
        {
            "id": "oauth_authorization_code",
            "title": "OAuth authorization code (connected sites)",
            "audience": "integrator",
            "steps": [
                "Redirect browser to GET /oauth/authorize?client_id=DOMAIN&redirect_uri=URL&response_type=code&state=CSRF",
                "Unauthenticated users are sent to /login?return_to=/oauth/authorize?...",
                "New users follow Create account with the same return_to; verification links include return_to.",
                "After email verification, users return to the OAuth consent screen (or /login?return_to=... if signed out).",
                "User approves on consent screen; POST /oauth/authorize issues redirect to redirect_uri?code=...&state=...",
                "In your callback handler, POST /oauth/token with the same redirect_uri, User-Agent, and Accept application/json.",
                "Store access_token server-side and use Authorization Bearer on /api/* routes."
            ]
        },
        {
            "id": "server_side_http",
            "title": "Server-to-server HTTP client",
            "audience": "integrator",
            "steps": [
                "Set account base URL to your HTTPS auth host (e.g. https://auth.timfalken.com).",
                "Send User-Agent identifying your site client (e.g. MySiteAccountClient/1.0).",
                "Send Accept: application/json on POST /oauth/token and all /api/* requests.",
                "POST /oauth/token with Content-Type application/x-www-form-urlencoded and the authorization code from the callback.",
                "Parse the JSON response; on success store access_token and user in your site session.",
                "Send Authorization: Bearer <access_token> on subsequent API calls with the same User-Agent and Accept headers."
            ]
        },
        {
            "id": "session_extend",
            "title": "Extend API session",
            "audience": "integrator",
            "steps": [
                "Before access_token expires (default 1 hour), call POST /api/session/extend with Bearer token.",
                "Response includes new expires_at and expires_in (3600).",
                "Call periodically during active use; expired tokens return 401."
            ]
        },
        {
            "id": "user_data_export",
            "title": "User data export (required on connected sites)",
            "audience": "integrator",
            "steps": [
                "Place a clearly labelled button in user settings, e.g. \"Export my data\" or \"Download my data (JSON)\".",
                "On click, your server aggregates all personal data stored for the signed-in user.",
                "Return application/json immediately \u2014 show it in a modal or offer a .json file download.",
                "Include profile/auth fields (from GET /api/me or your session), preferences, and user-owned content.",
                "Restrict access to the authenticated user only; do not require email requests or admin approval."
            ]
        }
    ],
    "endpoints": [
        {
            "method": "GET",
            "path": "/",
            "auth": "none",
            "description": "Home page; shows sign-in state and links.",
            "responses": [
                {
                    "status": 200,
                    "content_type": "text/html",
                    "description": "Home page with sign-in links or signed-in state."
                }
            ]
        },
        {
            "method": "GET",
            "path": "/help",
            "auth": "none",
            "description": "Human-readable integration and usage guide.",
            "responses": [
                {
                    "status": 200,
                    "content_type": "text/html",
                    "description": "Help page."
                },
                {
                    "status": 302,
                    "content_type": "redirect",
                    "location": "https://auth.timfalken.com/help.json",
                    "description": "When Accept prefers application/json."
                }
            ]
        },
        {
            "method": "GET",
            "path": "/help.json",
            "auth": "none",
            "description": "Machine-readable JSON specification of all auth functionality.",
            "responses": [
                {
                    "status": 200,
                    "content_type": "application/json",
                    "body": {
                        "schema_version": "1.1",
                        "service": "account-auth",
                        "endpoints": "[...]"
                    }
                }
            ]
        },
        {
            "method": "GET",
            "path": "/login",
            "auth": "none",
            "description": "Sign-in form. Supports return_to query param (relative path only).",
            "responses": [
                {
                    "status": 200,
                    "content_type": "text/html",
                    "description": "Login form. Query params: error, message, return_to."
                }
            ]
        },
        {
            "method": "POST",
            "path": "/login",
            "auth": "none",
            "content_type": "application/x-www-form-urlencoded",
            "body": [
                "csrf",
                "identifier",
                "password",
                "return_to?"
            ],
            "responses": [
                {
                    "status": 302,
                    "content_type": "redirect",
                    "location": "/",
                    "set_cookie": "account_session",
                    "description": "Success; session cookie set."
                },
                {
                    "status": 302,
                    "content_type": "redirect",
                    "location": "/oauth/authorize?...",
                    "description": "Success with safe return_to path."
                },
                {
                    "status": 302,
                    "content_type": "redirect",
                    "location": "/login?error=Invalid+username%2Femail+or+password.",
                    "description": "Invalid credentials."
                },
                {
                    "status": 302,
                    "content_type": "redirect",
                    "location": "/login?error=Invalid+session.+Please+try+again.",
                    "description": "Invalid CSRF token."
                }
            ]
        },
        {
            "method": "GET",
            "path": "/register",
            "auth": "none",
            "description": "Registration form.",
            "responses": [
                {
                    "status": 200,
                    "content_type": "text/html",
                    "description": "Registration form."
                }
            ]
        },
        {
            "method": "POST",
            "path": "/register",
            "auth": "none",
            "content_type": "application/x-www-form-urlencoded",
            "body": [
                "csrf",
                "username",
                "email",
                "password"
            ],
            "responses": [
                {
                    "status": 302,
                    "content_type": "redirect",
                    "location": "/admin?tab=domains&message=Account+created.+Check+your+email...",
                    "set_cookie": "account_session",
                    "description": "First user (admin) created."
                },
                {
                    "status": 302,
                    "content_type": "redirect",
                    "location": "/?message=Account+created.+Check+your+email...",
                    "set_cookie": "account_session",
                    "description": "Subsequent user created."
                },
                {
                    "status": 302,
                    "content_type": "redirect",
                    "location": "/register?error=Password+must+be+at+least+8+characters.",
                    "description": "Validation error."
                }
            ]
        },
        {
            "method": "POST",
            "path": "/logout",
            "auth": "web_cookie",
            "content_type": "application/x-www-form-urlencoded",
            "body": [
                "csrf"
            ],
            "responses": [
                {
                    "status": 302,
                    "content_type": "redirect",
                    "location": "/login?message=Signed+out.",
                    "description": "Cookie cleared and session revoked."
                }
            ]
        },
        {
            "method": "GET",
            "path": "/verify-email",
            "auth": "none",
            "query": [
                "token"
            ],
            "description": "Email verification link handler.",
            "responses": [
                {
                    "status": 302,
                    "content_type": "redirect",
                    "location": "/oauth/authorize?client_id=...",
                    "description": "Valid token with return_to while still signed in."
                },
                {
                    "status": 302,
                    "content_type": "redirect",
                    "location": "/login?return_to=%2Foauth%2Fauthorize%3F...&message=Email+verified.+You+can+now+continue.",
                    "description": "Valid token with return_to after sign-out."
                },
                {
                    "status": 302,
                    "content_type": "redirect",
                    "location": "/login?message=Email+verified.+You+can+now+continue.",
                    "description": "Valid token without return_to."
                },
                {
                    "status": 302,
                    "content_type": "redirect",
                    "location": "/login?error=Verification+link+is+invalid+or+has+expired.",
                    "description": "Invalid or expired token."
                }
            ]
        },
        {
            "method": "GET",
            "path": "/forgot-password",
            "auth": "none",
            "description": "Request password reset form.",
            "responses": [
                {
                    "status": 200,
                    "content_type": "text/html",
                    "description": "Forgot-password form."
                }
            ]
        },
        {
            "method": "POST",
            "path": "/forgot-password",
            "auth": "none",
            "body": [
                "csrf",
                "email"
            ],
            "description": "Always shows generic success message (no email enumeration).",
            "responses": [
                {
                    "status": 302,
                    "content_type": "redirect",
                    "location": "/forgot-password?message=If+an+account+with+a+verified+email+matches%2C+a+reset+link+has+been+sent.",
                    "description": "Always returned when request is accepted."
                }
            ]
        },
        {
            "method": "GET",
            "path": "/reset-password",
            "auth": "none",
            "query": [
                "token"
            ],
            "description": "Password reset form when token is valid.",
            "responses": [
                {
                    "status": 200,
                    "content_type": "text/html",
                    "description": "Reset form with hidden token field."
                },
                {
                    "status": 302,
                    "content_type": "redirect",
                    "location": "/login?error=Password+reset+link+is+invalid+or+has+expired.",
                    "description": "Invalid token on GET."
                }
            ]
        },
        {
            "method": "POST",
            "path": "/reset-password",
            "auth": "none",
            "body": [
                "csrf",
                "token",
                "password",
                "password_confirm"
            ],
            "responses": [
                {
                    "status": 302,
                    "content_type": "redirect",
                    "location": "/login?message=Password+updated.+Sign+in+with+your+new+password.",
                    "description": "Password updated; all user sessions revoked."
                },
                {
                    "status": 302,
                    "content_type": "redirect",
                    "location": "/reset-password?token=...&error=Passwords+do+not+match.",
                    "description": "Password confirmation mismatch."
                }
            ]
        },
        {
            "method": "POST",
            "path": "/resend-verification",
            "auth": "web_cookie",
            "body": [
                "csrf"
            ],
            "description": "Resend verification email for signed-in unverified user.",
            "responses": [
                {
                    "status": 302,
                    "content_type": "redirect",
                    "location": "/?message=Verification+email+sent.",
                    "description": "Email sent."
                },
                {
                    "status": 302,
                    "content_type": "redirect",
                    "location": "/?message=Your+email+is+already+verified.",
                    "description": "Already verified."
                }
            ]
        },
        {
            "method": "GET",
            "path": "/oauth/authorize",
            "auth": "web_cookie (after login redirect)",
            "query": [
                "client_id",
                "redirect_uri",
                "response_type=code",
                "state"
            ],
            "description": "OAuth authorization; shows consent when authenticated.",
            "example": "https://auth.timfalken.com/oauth/authorize?client_id=timfalken.com&redirect_uri=https%3A%2F%2Fblog.timfalken.com%2Fauth%2Fcallback&response_type=code&state=RANDOM",
            "responses": [
                {
                    "status": 200,
                    "content_type": "text/html",
                    "description": "Consent screen when user is signed in."
                },
                {
                    "status": 302,
                    "content_type": "redirect",
                    "location": "/login?return_to=%2Foauth%2Fauthorize%3F...",
                    "description": "Unauthenticated user sent to login first."
                },
                {
                    "status": 400,
                    "content_type": "application/json",
                    "body": {
                        "error": "client_id, redirect_uri, and state are required."
                    }
                },
                {
                    "status": 400,
                    "content_type": "application/json",
                    "body": {
                        "error": "Unknown client_id."
                    }
                }
            ]
        },
        {
            "method": "POST",
            "path": "/oauth/authorize",
            "auth": "web_cookie",
            "body": [
                "csrf",
                "client_id",
                "redirect_uri",
                "state",
                "approve"
            ],
            "responses": [
                {
                    "status": 302,
                    "content_type": "redirect",
                    "location": "https://blog.timfalken.com/auth/callback?code=AUTHORIZATION_CODE&state=CLIENT_STATE",
                    "description": "User approved; browser redirect with one-time code."
                },
                {
                    "status": 200,
                    "content_type": "application/json",
                    "body": {
                        "redirect": "https://blog.timfalken.com/auth/callback?code=AUTHORIZATION_CODE&state=CLIENT_STATE"
                    },
                    "description": "User approved; JSON redirect URL when Accept includes application/json."
                },
                {
                    "status": 400,
                    "content_type": "application/json",
                    "body": {
                        "error": "Invalid CSRF token."
                    }
                },
                {
                    "status": 401,
                    "content_type": "application/json",
                    "body": {
                        "error": "Authentication required."
                    }
                }
            ]
        },
        {
            "method": "POST",
            "path": "/oauth/token",
            "auth": "client_secret",
            "content_type": "application/x-www-form-urlencoded",
            "headers": {
                "Content-Type": "application/x-www-form-urlencoded",
                "Accept": "application/json",
                "User-Agent": "Client identifier (recommended: YourSiteAccountClient/1.0)"
            },
            "body": [
                "grant_type=authorization_code",
                "code",
                "client_id",
                "client_secret",
                "redirect_uri"
            ],
            "example_request": "POST https://auth.timfalken.com/oauth/token\nContent-Type: application/x-www-form-urlencoded\nAccept: application/json\nUser-Agent: MySiteAccountClient/1.0\n\ngrant_type=authorization_code&code=...&redirect_uri=...&client_id=...&client_secret=...",
            "responses": [
                {
                    "status": 200,
                    "content_type": "application/json",
                    "body": {
                        "access_token": "a1b2c3d4e5f6789012345678abcdef01",
                        "token_type": "Bearer",
                        "expires_in": 3600,
                        "user": {
                            "id": 1,
                            "username": "tim",
                            "email": "tim@example.com"
                        }
                    }
                },
                {
                    "status": 400,
                    "content_type": "application/json",
                    "body": {
                        "error": "Invalid client credentials."
                    }
                },
                {
                    "status": 400,
                    "content_type": "application/json",
                    "body": {
                        "error": "Invalid or expired authorization code."
                    }
                },
                {
                    "status": 400,
                    "content_type": "application/json",
                    "body": {
                        "error": "redirect_uri does not match the authorized value."
                    }
                },
                {
                    "status": 400,
                    "content_type": "application/json",
                    "body": {
                        "error": "Unsupported grant_type."
                    }
                }
            ]
        },
        {
            "method": "GET",
            "path": "/api/me",
            "auth": "bearer",
            "responses": [
                {
                    "status": 200,
                    "content_type": "application/json",
                    "body": {
                        "user": {
                            "id": 1,
                            "username": "tim",
                            "email": "tim@example.com",
                            "is_admin": false,
                            "email_verified": true
                        }
                    }
                },
                {
                    "status": 401,
                    "content_type": "application/json",
                    "body": {
                        "error": "Bearer token required."
                    }
                },
                {
                    "status": 401,
                    "content_type": "application/json",
                    "body": {
                        "error": "Invalid or expired token."
                    }
                }
            ]
        },
        {
            "method": "POST",
            "path": "/api/session/extend",
            "auth": "bearer",
            "description": "Sliding session renewal; resets TTL to session_ttl_seconds.",
            "example_request": "POST https://auth.timfalken.com/api/session/extend\nAuthorization: Bearer ACCESS_TOKEN\nAccept: application/json\nUser-Agent: MySiteAccountClient/1.0",
            "responses": [
                {
                    "status": 200,
                    "content_type": "application/json",
                    "body": {
                        "expires_at": "2026-06-05 19:00:00",
                        "expires_in": 3600
                    }
                },
                {
                    "status": 401,
                    "content_type": "application/json",
                    "body": {
                        "error": "Bearer token required."
                    }
                },
                {
                    "status": 401,
                    "content_type": "application/json",
                    "body": {
                        "error": "Invalid or expired token."
                    }
                }
            ]
        },
        {
            "method": "POST",
            "path": "/api/logout",
            "auth": "bearer",
            "description": "Revokes the bearer token server-side.",
            "responses": [
                {
                    "status": 200,
                    "content_type": "application/json",
                    "body": {
                        "ok": true
                    }
                },
                {
                    "status": 401,
                    "content_type": "application/json",
                    "body": {
                        "error": "Bearer token required."
                    }
                },
                {
                    "status": 401,
                    "content_type": "application/json",
                    "body": {
                        "error": "Invalid or expired token."
                    }
                }
            ]
        },
        {
            "method": "GET",
            "path": "/admin",
            "auth": "admin_web_cookie",
            "query": [
                "tab=domains|smtp"
            ],
            "description": "Admin UI for OAuth domains and SMTP settings.",
            "responses": [
                {
                    "status": 200,
                    "content_type": "text/html",
                    "description": "Admin panel."
                },
                {
                    "status": 302,
                    "content_type": "redirect",
                    "location": "/login?error=Admin+access+required.",
                    "description": "Non-admin or guest."
                }
            ]
        },
        {
            "method": "POST",
            "path": "/admin/domains",
            "auth": "admin_web_cookie",
            "body": [
                "csrf",
                "domain",
                "paths (newline-separated)"
            ],
            "responses": [
                {
                    "status": 302,
                    "content_type": "redirect",
                    "location": "/admin?tab=domains&message=Domain+registered.",
                    "description": "Domain added; new client_secret flashed once in session."
                }
            ]
        },
        {
            "method": "POST",
            "path": "/admin/smtp",
            "auth": "admin_web_cookie",
            "description": "Save SMTP settings; blank password keeps existing password.",
            "responses": [
                {
                    "status": 302,
                    "content_type": "redirect",
                    "location": "/admin?tab=smtp&message=SMTP+settings+saved.",
                    "description": "Settings saved."
                }
            ]
        },
        {
            "method": "POST",
            "path": "/admin/smtp/test",
            "auth": "admin_web_cookie",
            "description": "Send test email using form values.",
            "responses": [
                {
                    "status": 302,
                    "content_type": "redirect",
                    "location": "/admin?tab=smtp&message=Test+email+sent+to+admin%40example.com.",
                    "description": "Test email sent."
                }
            ]
        }
    ],
    "user_object": {
        "api_me": {
            "id": "integer user id",
            "username": "string",
            "email": "string",
            "is_admin": "boolean; first registered user is admin",
            "email_verified": "boolean; required for password reset emails"
        },
        "oauth_token": {
            "id": "integer user id",
            "username": "string",
            "email": "string"
        }
    },
    "integration_checklist": [
        "Deploy over HTTPS and set account_url in config.local.php if needed.",
        "Admin registers client domain and callback paths at /admin.",
        "Store client_secret server-side only.",
        "Redirect users to GET /oauth/authorize with state for CSRF protection.",
        "In the callback handler, POST /oauth/token with Content-Type application/x-www-form-urlencoded.",
        "Send User-Agent and Accept application/json on every server-to-server account request.",
        "Persist access_token server-side (session) and use Authorization Bearer on /api/* calls.",
        "Call GET /api/me to validate tokens; POST /api/session/extend before expiry.",
        "Call POST /api/logout to revoke bearer tokens on sign-out.",
        "Add a user-settings control that immediately exports all of that user's data as JSON (modal or .json file download)."
    ]
}