Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 0 additions & 5 deletions .env.exampl

This file was deleted.

36 changes: 36 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
DATABASE_URL=postgresql://postgres.xxxxxxxx:your-password@aws-1-eu-west-3.pooler.supabase.com:5432/postgres?sslmode=require
JWT_SECRET=
CORS_ORIGINS=http://localhost:5173,https://yourapp.com
PORT=8080
ACCESS_TOKEN_TTL_MINUTES=15
BASE_URL=http://localhost:8080

# OAuth providers are optional; one missing its ID or secret is simply
# unavailable (404 oauth_provider_not_configured), not a startup error.
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
MICROSOFT_CLIENT_ID=
MICROSOFT_CLIENT_SECRET=
DISCORD_CLIENT_ID=
DISCORD_CLIENT_SECRET=
GITLAB_CLIENT_ID=
GITLAB_CLIENT_SECRET=

# Apple needs all four (its "secret" is a JWT this API signs with the .p8
# key, so there is no static secret). Write the key's newlines as \n.
APPLE_CLIENT_ID=
APPLE_TEAM_ID=
APPLE_KEY_ID=
APPLE_PRIVATE_KEY=

# Second factors. ENCRYPTION_KEY is required for TOTP and/or passkeys and
# encrypts TOTP secrets at rest — treat it like JWT_SECRET. Leave it unset
# to run password-only; the second-factor endpoints then answer 404
# rather than the server refusing to start.
ENCRYPTION_KEY=
TOTP_ISSUER_NAME=
WEBAUTHN_RP_ID=
WEBAUTHN_RP_DISPLAY_NAME=
WEBAUTHN_RP_ORIGINS=
78 changes: 76 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ cp .env.example .env # fill in DATABASE_URL, JWT_SECRET, CORS_ORIGINS
go run .
```

Run the migrations in `migrations/` against your database first (copies of CrydenSync's own migrations, kept here so this repo is self-contained for local dev and CI — same as `typebook` keeps its own copy). `002_oauth_identities` is required even if you don't use OAuth yet — `NewOAuthStore` is wired into the engine config unconditionally.
Run the migrations in `migrations/` against your database first, in order (copies of CrydenSync's own migrations, kept here so this repo is self-contained for local dev and CI — same as `typebook` keeps its own copy). `002_oauth_identities` is required even if you don't use OAuth yet — `NewOAuthStore` is wired into the engine config unconditionally. `004` through `008` are the TOTP, WebAuthn, recovery-code, login-attempt and API-key tables; run them even if you leave `ENCRYPTION_KEY` unset, since `007` is what the engine's credential-stuffing detection reads once Tier 2 wires it up and `008` is what the API-key work will use.

OAuth is optional. To enable a provider, set its client ID/secret plus `BASE_URL` (used to build the callback URL registered in that provider's console):

Expand All @@ -32,6 +32,55 @@ GITHUB_CLIENT_SECRET=...

A provider missing its client ID or secret is simply unavailable — its endpoints return `404 oauth_provider_not_configured` rather than the server refusing to start.

Supported providers are `google`, `github`, `microsoft`, `discord`, `gitlab` and `apple`.

`google`/`github`/`microsoft`/`discord`/`gitlab` all have the same authorization-code shape, so each is one case in `httpapi/oauth_handlers.go` plus its env vars:

```
MICROSOFT_CLIENT_ID=... MICROSOFT_CLIENT_SECRET=...
DISCORD_CLIENT_ID=... DISCORD_CLIENT_SECRET=...
GITLAB_CLIENT_ID=... GITLAB_CLIENT_SECRET=...
```

Apple is the one that does not fit that shape, and it is the only provider needing more than an ID and a secret:

```
APPLE_CLIENT_ID=com.example.web # your Services ID, not the app bundle ID
APPLE_TEAM_ID=XXXXXXXXXX
APPLE_KEY_ID=XXXXXXXXXX
APPLE_PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\nMIG...\n-----END PRIVATE KEY-----
```

- Its client "secret" is a short-lived ES256 JWT this API signs itself (`httpapi/apple.go`), which is why it needs the `.p8` key rather than a string. In `.env`, write the key's newlines as `\n`; a real multiline value passed through a secret manager is used as-is.
- There is no userinfo endpoint. The email and account ID come from the `id_token` in the token response, verified against Apple's published signing keys (issuer, audience, expiry and RS256 all enforced) rather than merely decoded.
- The authorization request pins `response_mode=query`, so the existing GET callback route works unchanged. Consequently the one-time `user` payload (the name Apple sends only on a first authorization) is not captured — this API stores the id_token's email, not names.

All four `APPLE_*` values are required; a partially configured Apple is simply unavailable, like any other unconfigured provider.

## Second factors

TOTP and passkeys are optional and all-or-nothing on `ENCRYPTION_KEY`: cryden refuses to construct an engine with a TOTP or WebAuthn store set and no encryption key (a TOTP secret must be recoverable in plaintext to check a code, so it is encrypted rather than hashed). With the key unset, both methods answer `404 totp_not_configured` / `404 passkeys_not_configured` per request, the same shape an unconfigured OAuth provider uses, rather than the server refusing to start.

```
ENCRYPTION_KEY=... # required for TOTP and/or passkeys
TOTP_ISSUER_NAME=YourApp # cosmetic; shown in the authenticator app
WEBAUTHN_RP_ID=yourapp.com # your real registrable domain — a security parameter, not a label
WEBAUTHN_RP_DISPLAY_NAME=Your App Inc
WEBAUTHN_RP_ORIGINS=https://yourapp.com
```

Passkeys also need all three `WEBAUTHN_*` values; setting only some of them logs a startup warning and leaves passkeys off.

Magic-link login needs no extra configuration — it reuses the same `Verifications` store email change uses, and `consoleMagicLinkSender` (in `email_sender.go`) is a dev stand-in exactly like `consoleEmailSender`.

**Once an account has a confirmed second factor, a correct password is no longer enough.** `POST /v1/login`, the OAuth callback and `/v1/magic-link/complete` all answer `200` with:

```json
{ "data": { "second_factor_required": true, "pending_token": "...", "methods": ["totp", "webauthn"] } }
```

`pending_token` is then handed to whichever completion endpoint matches a listed method: `/v1/login/totp`, `/v1/login/passkey/begin` + `/v1/login/passkey/finish`, or `/v1/login/recovery-code`. It is not an access or refresh token and does nothing on any other endpoint.

## Rate limiting

Two independent layers:
Expand All @@ -52,6 +101,12 @@ Every response follows one of two shapes:

`code` is the stable string to branch on programmatically. `message` is for humans — never parse it.

One error carries a third, optional key: `password_policy_violation` includes a `details` array holding every broken rule at once as stable codes (`min_length`, `max_length`, `require_uppercase`, `require_lowercase`, `require_digit`, `require_symbol`), so a client can list them together instead of discovering one per submit. Every other error has exactly the two keys above.

```json
{ "error": { "code": "password_policy_violation", "message": "password does not meet the required policy", "details": ["min_length", "require_digit"] } }
```

## Endpoints

```
Expand All @@ -72,9 +127,25 @@ GET /v1/oauth/{provider}/callback
GET /v1/oauth/{provider}/link (auth required)
GET /v1/oauth/{provider}/link/callback
GET /v1/health

POST /v1/totp/enroll (auth required)
POST /v1/totp/confirm (auth required)
POST /v1/totp/disable (auth required)
POST /v1/passkeys/register/begin (auth required)
POST /v1/passkeys/register/finish (auth required)
GET /v1/passkeys (auth required)
DELETE /v1/passkeys/{credentialID} (auth required, password in body)
POST /v1/recovery-codes/generate (auth required)
POST /v1/magic-link/request
POST /v1/magic-link/complete
POST /v1/login/totp (completes a paused login)
POST /v1/login/passkey/begin (completes a paused login)
POST /v1/login/passkey/finish (completes a paused login)
POST /v1/login/recovery-code (completes a paused login)
```

`{provider}` is `google` or `github`. The two OAuth flows are separate
`{provider}` is `google`, `github`, `microsoft`, `discord`, `gitlab` or `apple`.
The two OAuth flows are separate
on purpose:
- `/oauth/{provider}` → `/oauth/{provider}/callback` is login/signup —
no auth required, since this IS how you get authenticated.
Expand All @@ -98,6 +169,9 @@ Authenticated endpoints expect `Authorization: Bearer <access_token>`.
- `consoleEmailSender` (in `email_sender.go`) is a dev stand-in — logs verification tokens to the console instead of sending real email. Replace with a real provider (Resend, SES, SendGrid) before real users depend on email verification.
- Every engine error is mapped to a stable `(status, code)` pair in `httpapi/errors.go` — add new engine errors there once, every handler benefits. `*auth.ErrOAuthEmailConflict` is the one non-sentinel case in that file (it's a struct carrying `Email`/`Provider`, unwrapped via `errors.As` rather than `errors.Is`).
- The OAuth linking flow's HMAC-signed cookie (`oauth_handlers.go`) is genuinely new plumbing, not copied from an existing pattern elsewhere in this repo — worth reading closely if you're touching that code, not just trusting it because it compiles.
- A paused login is a `200`, not an error: nothing failed, the caller just has one more step. `httpapi/second_factor.go` is the one place that response shape is written.
- `DELETE /v1/passkeys/{credentialID}` takes a JSON body (`{"password": "..."}`) — the password is re-confirmation, so a stolen access token alone cannot weaken an account's own auth requirements.
- Passkey ceremony options and the browser's credential response travel as raw JSON (an object, not a JSON-encoded string), since that is exactly what `navigator.credentials.create()`/`.get()` produce and consume.

## License

Expand Down
75 changes: 75 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,48 @@ type Config struct {
GoogleClientSecret string
GitHubClientID string
GitHubClientSecret string

MicrosoftClientID string
MicrosoftClientSecret string
DiscordClientID string
DiscordClientSecret string
GitLabClientID string
GitLabClientSecret string

// Apple is the one provider that needs more than an ID and a secret:
// its client "secret" is a short-lived ES256 JWT this repo signs
// itself, with a key downloaded from Apple's developer console, so
// the signing material is configuration here rather than a static
// string. All four must be set for the provider to be available.
//
// AppleClientID is the Services ID (e.g. "com.example.web"), not the
// app bundle ID. ApplePrivateKey is the .p8 file's contents; because
// a PEM cannot sit on one .env line, literal "\n" sequences are
// converted to real newlines when this is loaded.
AppleClientID string
AppleTeamID string
AppleKeyID string
ApplePrivateKey string

// EncryptionKey encrypts TOTP secrets and WebAuthn ceremony state at
// rest. Required only if TOTP or WebAuthn is enabled — cryden refuses
// to construct an engine with either store set and this empty, since a
// TOTP secret has to be recoverable in plaintext to check a code, so
// it is encrypted rather than hashed. Treat it with the same care as
// JWT_SECRET.
EncryptionKey string

// TOTPIssuerName is what the user's authenticator app shows next to
// the account. Cosmetic. Empty means cryden's own default ("Cryden").
TOTPIssuerName string

// WebAuthnRPID is the app's real registrable domain — passkeys are
// cryptographically bound to it, so unlike TOTPIssuerName this is a
// genuine security parameter, not a label. WebAuthnRPOrigins must
// list the exact scheme+host+port the browser will send.
WebAuthnRPID string
WebAuthnRPDisplayName string
WebAuthnRPOrigins []string
}

// Load reads .env (if present, filling only gaps — real env vars
Expand Down Expand Up @@ -90,6 +132,39 @@ func Load() (Config, error) {
cfg.GoogleClientSecret = os.Getenv("GOOGLE_CLIENT_SECRET")
cfg.GitHubClientID = os.Getenv("GITHUB_CLIENT_ID")
cfg.GitHubClientSecret = os.Getenv("GITHUB_CLIENT_SECRET")
cfg.MicrosoftClientID = os.Getenv("MICROSOFT_CLIENT_ID")
cfg.MicrosoftClientSecret = os.Getenv("MICROSOFT_CLIENT_SECRET")
cfg.DiscordClientID = os.Getenv("DISCORD_CLIENT_ID")
cfg.DiscordClientSecret = os.Getenv("DISCORD_CLIENT_SECRET")
cfg.GitLabClientID = os.Getenv("GITLAB_CLIENT_ID")
cfg.GitLabClientSecret = os.Getenv("GITLAB_CLIENT_SECRET")
cfg.AppleClientID = os.Getenv("APPLE_CLIENT_ID")
cfg.AppleTeamID = os.Getenv("APPLE_TEAM_ID")
cfg.AppleKeyID = os.Getenv("APPLE_KEY_ID")
// .env files are line-oriented, so a PEM arrives with its newlines
// written as \n. Only unescape when the value looks like it
// needs it, so a deployment that already passes a real multiline
// value through its own secret manager is left alone.
cfg.ApplePrivateKey = os.Getenv("APPLE_PRIVATE_KEY")
if strings.Contains(cfg.ApplePrivateKey, `\n`) {
cfg.ApplePrivateKey = strings.ReplaceAll(cfg.ApplePrivateKey, `\n`, "\n")
}

// Second factors are optional too, and for the same reason: a
// deployment that hasn't set ENCRYPTION_KEY should still run fine for
// password-only auth. main.go only wires the TOTP/WebAuthn stores when
// the key is present, and cryden then reports those methods as
// unavailable (404, same shape as an unconfigured OAuth provider)
// rather than the server refusing to start.
cfg.EncryptionKey = os.Getenv("ENCRYPTION_KEY")
cfg.TOTPIssuerName = os.Getenv("TOTP_ISSUER_NAME")
cfg.WebAuthnRPID = os.Getenv("WEBAUTHN_RP_ID")
cfg.WebAuthnRPDisplayName = os.Getenv("WEBAUTHN_RP_DISPLAY_NAME")
if origins := os.Getenv("WEBAUTHN_RP_ORIGINS"); origins != "" {
for _, o := range strings.Split(origins, ",") {
cfg.WebAuthnRPOrigins = append(cfg.WebAuthnRPOrigins, strings.TrimSpace(o))
}
}

return cfg, nil
}
Expand Down
Loading
Loading