{"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 — 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 — 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)."]}