Help & integration guide
How to sign in, use OAuth, call the API, and manage sessions on this account service.
Overview
Central login, OAuth2-style authorization code flow, bearer-token API, email verification, and password reset for connected sites.
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
stateis required — verify it matches on callback.- Query strings on
redirect_uriare stripped; register the path only. - Subdomains of the registered domain are allowed.
- Unauthenticated users are sent to
/loginand 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/meor 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
.jsonfile 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— forPOST /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"}withapplication/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.
GET /help
Human-readable integration and usage guide.
Location: https://auth.timfalken.com/help.json
GET /help.json
Machine-readable JSON specification of all auth functionality.
{
"schema_version": "1.1",
"service": "account-auth",
"endpoints": "[...]"
}
GET /login
Sign-in form. Supports return_to query param (relative path only).
POST /login
Location: /
Sets cookie account_session.
Location: /oauth/authorize?...
Location: /login?error=Invalid+username%2Femail+or+password.
Location: /login?error=Invalid+session.+Please+try+again.
GET /register
Registration form.
POST /register
Location: /admin?tab=domains&message=Account+created.+Check+your+email...
Sets cookie account_session.
Location: /?message=Account+created.+Check+your+email...
Sets cookie account_session.
Location: /register?error=Password+must+be+at+least+8+characters.
POST /logout
Location: /login?message=Signed+out.
GET /verify-email
Email verification link handler.
Location: /oauth/authorize?client_id=...
Location: /login?return_to=%2Foauth%2Fauthorize%3F...&message=Email+verified.+You+can+now+continue.
Location: /login?message=Email+verified.+You+can+now+continue.
Location: /login?error=Verification+link+is+invalid+or+has+expired.
GET /forgot-password
Request password reset form.
POST /forgot-password
Always shows generic success message (no email enumeration).
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.
Location: /login?error=Password+reset+link+is+invalid+or+has+expired.
POST /reset-password
Location: /login?message=Password+updated.+Sign+in+with+your+new+password.
Location: /reset-password?token=...&error=Passwords+do+not+match.
POST /resend-verification
Resend verification email for signed-in unverified user.
Location: /?message=Verification+email+sent.
Location: /?message=Your+email+is+already+verified.
GET /oauth/authorize
OAuth authorization; shows consent when authenticated.
Location: /login?return_to=%2Foauth%2Fauthorize%3F...
{
"error": "client_id, redirect_uri, and state are required."
}
{
"error": "Unknown client_id."
}
POST /oauth/authorize
Location: https://blog.timfalken.com/auth/callback?code=AUTHORIZATION_CODE&state=CLIENT_STATE
{
"redirect": "https://blog.timfalken.com/auth/callback?code=AUTHORIZATION_CODE&state=CLIENT_STATE"
}
{
"error": "Invalid CSRF token."
}
{
"error": "Authentication required."
}
POST /oauth/token
{
"access_token": "a1b2c3d4e5f6789012345678abcdef01",
"token_type": "Bearer",
"expires_in": 3600,
"user": {
"id": 1,
"username": "tim",
"email": "tim@example.com"
}
}
{
"error": "Invalid client credentials."
}
{
"error": "Invalid or expired authorization code."
}
{
"error": "redirect_uri does not match the authorized value."
}
{
"error": "Unsupported grant_type."
}
GET /api/me
{
"user": {
"id": 1,
"username": "tim",
"email": "tim@example.com",
"is_admin": false,
"email_verified": true
}
}
{
"error": "Bearer token required."
}
{
"error": "Invalid or expired token."
}
POST /api/session/extend
Sliding session renewal; resets TTL to session_ttl_seconds.
{
"expires_at": "2026-06-05 19:00:00",
"expires_in": 3600
}
{
"error": "Bearer token required."
}
{
"error": "Invalid or expired token."
}
POST /api/logout
Revokes the bearer token server-side.
{
"ok": true
}
{
"error": "Bearer token required."
}
{
"error": "Invalid or expired token."
}
GET /admin
Admin UI for OAuth domains and SMTP settings.
Location: /login?error=Admin+access+required.
POST /admin/domains
Location: /admin?tab=domains&message=Domain+registered.
POST /admin/smtp
Save SMTP settings; blank password keeps existing password.
Location: /admin?tab=smtp&message=SMTP+settings+saved.
POST /admin/smtp/test
Send test email using form values.
Location: /admin?tab=smtp&message=Test+email+sent+to+admin%40example.com.
Sessions
- Web sessions use the
account_sessioncookie (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
401on 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=smtpand 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)."
]
}