GitHub OAuth is the sole primary auth method. Sessions are stateless JWTs (per behaviors/authorization.md). Email/password sign-in does not exist — see deferred.md.
| Method | Path | Auth | Summary |
|---|---|---|---|
GET |
/api/auth/github/start |
public | Begin GitHub OAuth flow. Redirects to GitHub. |
GET |
/api/auth/github/callback |
public | OAuth callback. Exchanges code for tokens, resolves identity, issues session or routes to claim flow. |
GET |
/api/auth/me |
public (with optional session) | Returns current Person + accountLevel, or anonymous. |
POST |
/api/auth/refresh |
refresh-cookie | Mint a new access+refresh pair. |
POST |
/api/auth/logout |
user | End the current session. |
GET |
/api/auth/sessions |
user | List remembered sessions. |
POST |
/api/auth/sessions/:jti/revoke |
user (self) | Revoke a specific session. |
The account-claim flow (/api/account-claim/*) is documented in api/account-claim.md. It's invoked from /api/auth/github/callback when the OAuth identity doesn't match an existing linked Person but matches a legacy candidate.
Initiates the GitHub OAuth flow.
| Param | Required | Notes |
|---|---|---|
return |
no | Same-origin path to navigate to after successful sign-in. URL-encoded. Ignored if not same-origin. Defaults to /. |
-
Generate a CSRF state token (32 bytes CSPRNG, base64url), store in a short-lived (10 min) HttpOnly cookie
cfp_oauth_state -
Generate a one-time PKCE code verifier (per RFC 7636); compute the code challenge
-
Persist
{ state, codeVerifier, return }in a short-lived (10 min) signed cookiecfp_oauth_session(signed with the JWT signing key, not encrypted — it doesn't carry secrets needing confidentiality) -
Redirect the browser to:
https://github.com/login/oauth/authorize ?client_id=<GITHUB_OAUTH_CLIENT_ID> &redirect_uri=https://codeforphilly.org/api/auth/github/callback &scope=read:user user:email &state=<state> &code_challenge=<challenge> &code_challenge_method=S256
The read:user user:email scope set is the minimum: profile + verified emails. We do not request repo or anything else.
400 bad_request— invalidreturnURL (not same-origin, malformed) → ignored and replaced with/. Not a hard error.
Handles the OAuth callback after the user authorizes (or denies) on GitHub.
| Param | From GitHub | Notes |
|---|---|---|
code |
success | OAuth authorization code |
state |
success | CSRF state echo |
error |
failure | GitHub error code (e.g., access_denied) |
error_description |
failure | Human-readable error |
- Validate state. Compare
statequery param against thecfp_oauth_statecookie. Mismatch →401witherror.code = "oauth_state_mismatch". Clear the cookie either way. - Validate cfp_oauth_session. Verify signature, extract
{ codeVerifier, return }. Tampered →401. Clear the cookie. - Handle denial. If
erroris present (access_denied, etc.): redirect to/login?error=<error>so the SPA can render a friendly message. - Exchange code for tokens. POST
https://github.com/login/oauth/access_tokenwithclient_id,client_secret,code,code_verifier. Get back an access token. - Fetch user identity. GET
https://api.github.com/userwith the access token →{ id, login, name, ... }. GEThttps://api.github.com/user/emails→[{ email, primary, verified }, ...]. - Resolve identity to a Person — see behaviors/account-migration.md for the matching algorithm. Outcome is one of:
- a) Existing linked Person (
Person.githubUserId === gh.id). RefreshPerson.githubLogin, updatePrivateProfile.emailto the latest GitHub primary verified email, issue session, redirect toreturn. - b) New Person needed, no legacy match. Create a fresh
Person+PrivateProfile, link the GitHub identity, issue session, redirect. - c) Legacy candidate(s) found. Issue a short-lived claim-pending JWT (5 minutes, scope
claim) and redirect to/account-claim?candidates=.... The user confirms or declines, finalizing identity via api/account-claim.md.
- a) Existing linked Person (
In every successful case the user is redirected to either return (validated same-origin) or /account-claim. The redirect carries Set-Cookie headers for the session JWTs (cases a, b) or for the claim-pending JWT (case c).
401 unauthenticatedwith codeoauth_state_mismatch— CSRF failure401 unauthenticatedwith codeoauth_session_invalid— signed-session cookie tampered/expired502 bad_gatewaywith codegithub_unreachable— GitHub API call failed; user redirected to/login?error=github_unreachable403 forbiddenwith codeemail_unverified— GitHub returned no verified email (user has email visibility off AND no verified primary); user redirected to/login?error=email_unverifiedwith a help message about GitHub email visibility
Returns the current Person (full PersonResponse shape — see api/people.md) plus accountLevel. Used by the SPA on load to bootstrap the auth context.
{
"success": true,
"data": {
"person": { /* PersonResponse */ },
"accountLevel": "staff"
}
}If no session, returns 200 with data.person = null and data.accountLevel = "anonymous". (We deliberately do not 401 here — the frontend calls this on every page load including public pages.)
The PersonResponse for self includes email (fetched from PrivateProfile) and newsletter state. For staff viewing other people, see api/people.md on which private fields are visible.
Mints a new access+refresh JWT pair from a valid refresh JWT. Implementation unchanged from the earlier Phase 1 spec.
Empty body. Sets fresh cfp_session and cfp_refresh cookies.
401 unauthenticatedwitherror.code = "refresh_token_expired"401 unauthenticatedwitherror.code = "refresh_token_revoked"401 unauthenticatedwitherror.code = "no_refresh_token"
Revokes the current access + refresh JWT jtis (writes to the revocations sheet — see data-model.md#revocation) and clears the session cookies.
Lists remembered sessions (non-revoked refresh-token jtis with side-channel metadata). See behaviors/authorization.md for the "what's a session" framing.
{
"success": true,
"data": [
{
"jti": "<uuidv7>",
"userAgent": "Mozilla/5.0 ...",
"ipAddress": "1.2.3.4",
"issuedAt": "...",
"expiresAt": "...",
"current": true
}
]
}Note: userAgent and ipAddress here come from the in-memory session-metadata map, which is populated at JWT issue time and persists across restarts via a small sidecar in the private bucket. They are never included in commit trailers on the public repo — see behaviors/storage.md.
Revokes a non-current session by jti. Unchanged from Phase 1.
404 not_found—jtidoesn't match a session we have metadata for (or doesn't belong to caller)409 conflictwitherror.code = "cannot_revoke_current_session"
- No email/password endpoints.
/api/auth/register,/api/auth/login,/api/auth/password-reset/*do not exist. Trying to call them returns404 not_found. - GitHub identity is immutable per Person. Once
Person.githubUserIdis set, it doesn't change. Unlinking GitHub is not a v1 feature; if a user loses access to their GitHub account, they recover through a staff-mediated process. See behaviors/account-migration.md. - Email is GitHub-sourced.
PrivateProfile.emailis refreshed on every successful OAuth callback to the user's current primary verified GitHub email. We don't expose a "change email" UI; users change their email on GitHub. - The OAuth state cookie expires aggressively (10 minutes) so abandoned flows don't accumulate.
- PKCE is required even though we have a client secret on the server — PKCE protects against authorization-code interception in addition to whatever client-secret protection we already have.