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
6 changes: 2 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,8 @@ on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]

jobs:
build-and-smoketest:
branches: [ "main" ]
jobs: build-and-smoketest:
runs-on: ubuntu-latest

services:
Expand Down
37 changes: 35 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,19 @@ cp .env.example .env # fill in DATABASE_URL, JWT_SECRET, CORS_ORIGINS
go run .
```

Run the migration in `migrations/0001_initial_schema.up.sql` against your database first (this is a copy of CrydenSync's own migration, kept here so this repo is self-contained for local dev and CI — same as `typebook` keeps its own copy).
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.

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):

```
BASE_URL=https://api.example.com
GOOGLE_CLIENT_ID=...
GOOGLE_CLIENT_SECRET=...
GITHUB_CLIENT_ID=...
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.

## Rate limiting

Expand Down Expand Up @@ -48,16 +60,37 @@ POST /v1/change-password (auth required)
POST /v1/delete-account (auth required)
POST /v1/email/request-change (auth required)
POST /v1/email/confirm-change
GET /v1/oauth/{provider}
GET /v1/oauth/{provider}/callback
GET /v1/oauth/{provider}/link (auth required)
GET /v1/oauth/{provider}/link/callback
GET /v1/health
```

`{provider}` is `google` or `github`. 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.
- `/oauth/{provider}/link` → `/oauth/{provider}/link/callback` attaches
a provider to an already-logged-in user. `/link` itself requires a
Bearer token, but `/link/callback` deliberately does NOT — a browser
redirect to the provider and back carries no Authorization header,
so `/link` signs the caller's user ID into a short-lived cookie
instead, verified again at `/link/callback`.

A login attempt whose email matches an existing password-based account
returns `409 oauth_email_conflict` instead of silently linking the two
— the client should route the user to log in with their password, then
call `/oauth/{provider}/link` while authenticated to resolve it.

Authenticated endpoints expect `Authorization: Bearer <access_token>`.

## Design notes

- `CORS_ORIGINS` is required, no wildcard default — an API handling auth tokens should never allow every origin.
- `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.
- 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.

## License

Expand Down
21 changes: 21 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,16 @@ type Config struct {
AccessTokenTTL time.Duration
EdgeRateLimit int
EdgeRateLimitWindow time.Duration

// BaseURL is this api deployment's own public URL, used to build
// OAuth callback URLs (e.g. BaseURL + "/v1/oauth/google/callback")
// that get registered with each provider's console.
BaseURL string

GoogleClientID string
GoogleClientSecret string
GitHubClientID string
GitHubClientSecret string
}

// Load reads .env (if present, filling only gaps — real env vars
Expand Down Expand Up @@ -70,6 +80,17 @@ func Load() (Config, error) {
cfg.EdgeRateLimit = n
}

// OAuth is deliberately optional at config-load time — a
// deployment that hasn't set these up yet should still run fine
// for password-based auth. httpapi.NewRouter only registers the
// OAuth routes for providers that actually have both a client ID
// and secret set.
cfg.BaseURL = strings.TrimRight(os.Getenv("BASE_URL"), "/")
cfg.GoogleClientID = os.Getenv("GOOGLE_CLIENT_ID")
cfg.GoogleClientSecret = os.Getenv("GOOGLE_CLIENT_SECRET")
cfg.GitHubClientID = os.Getenv("GITHUB_CLIENT_ID")
cfg.GitHubClientSecret = os.Getenv("GITHUB_CLIENT_SECRET")

return cfg, nil
}

Expand Down
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,5 @@ require (
github.com/google/uuid v1.6.0 // indirect
golang.org/x/crypto v0.54.0 // indirect
)

replace github.com/crydensync/cryden/v2 => ../cryden
2 changes: 0 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
github.com/crydensync/cryden/v2 v2.0.0 h1:PgTQxo12nsMLaSS3gqO5i0UeNH4h4h/5eYvv7vKA630=
github.com/crydensync/cryden/v2 v2.0.0/go.mod h1:kkLk2779IPHbbj7aBmieH2jZRkwWGzyTmHHWKojAQEQ=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
Expand Down
34 changes: 34 additions & 0 deletions httpapi/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@

import (
"errors"
"fmt"
"net/http"

"github.com/crydensync/cryden/v2/auth"

Check failure on line 8 in httpapi/errors.go

View workflow job for this annotation

GitHub Actions / release

github.com/crydensync/cryden/v2@v2.0.0: replacement directory ../cryden does not exist
"github.com/crydensync/cryden/v2/store"

Check failure on line 9 in httpapi/errors.go

View workflow job for this annotation

GitHub Actions / release

github.com/crydensync/cryden/v2@v2.0.0: replacement directory ../cryden does not exist
"github.com/crydensync/cryden/v2/token"

Check failure on line 10 in httpapi/errors.go

View workflow job for this annotation

GitHub Actions / release

github.com/crydensync/cryden/v2@v2.0.0: replacement directory ../cryden does not exist
)

// apiError is the (status, code, message) triple every handler
Expand Down Expand Up @@ -34,12 +35,31 @@
// code to the client; the distinction only matters server-side.
var errEdgeRateLimited = errors.New("too many requests")

// The following three are local, API-layer-only errors from the
// OAuth redirect/callback flow itself — never returned by the engine,
// which never touches HTTP or a specific provider.
var errOAuthProviderNotConfigured = errors.New("oauth provider not configured")
var errOAuthStateMismatch = errors.New("oauth state parameter missing or mismatched")
var errOAuthEmailNotAvailable = errors.New("oauth provider did not return a usable email address")
var errOAuthLinkNotConfigured = errors.New("oauth linking is not available: server is missing a signing secret")
var errOAuthLinkSessionMissing = errors.New("oauth link session missing, expired, or tampered with — please retry")

func mapError(err error) apiError {
switch {
case errors.Is(err, errMissingAuthHeader):
return apiError{http.StatusUnauthorized, "missing_auth_header", "missing or malformed Authorization header"}
case errors.Is(err, errEdgeRateLimited):
return apiError{http.StatusTooManyRequests, "rate_limited", "too many requests, please slow down"}
case errors.Is(err, errOAuthProviderNotConfigured):
return apiError{http.StatusNotFound, "oauth_provider_not_configured", "this OAuth provider is not configured on this deployment"}
case errors.Is(err, errOAuthStateMismatch):
return apiError{http.StatusBadRequest, "oauth_state_mismatch", "oauth state parameter missing or mismatched — please retry the login"}
case errors.Is(err, errOAuthEmailNotAvailable):
return apiError{http.StatusBadRequest, "oauth_email_not_available", "could not retrieve a usable email address from the provider"}
case errors.Is(err, errOAuthLinkNotConfigured):
return apiError{http.StatusInternalServerError, "oauth_link_not_configured", "oauth linking is not available on this deployment"}
case errors.Is(err, errOAuthLinkSessionMissing):
return apiError{http.StatusBadRequest, "oauth_link_session_missing", "oauth link session missing, expired, or invalid — please retry"}
case errors.Is(err, auth.ErrInvalidCredentials):
return apiError{http.StatusUnauthorized, "invalid_credentials", "invalid email or password"}
case errors.Is(err, auth.ErrUserExists):
Expand All @@ -65,7 +85,21 @@
return apiError{http.StatusUnauthorized, "token_reused", "token reuse detected, all sessions for this device chain have been revoked"}
case errors.Is(err, token.ErrInvalidAccessToken):
return apiError{http.StatusUnauthorized, "invalid_access_token", "access token is invalid or expired"}
case errors.Is(err, auth.ErrOAuthIdentityAlreadyLinked):
return apiError{http.StatusConflict, "oauth_identity_already_linked", "this provider account is already linked to a different user"}
default:
// Struct-typed errors (not plain sentinels) need errors.As,
// not errors.Is — ErrOAuthEmailConflict carries Email and
// Provider that the client needs, so it can't just be a case
// in the switch above like the sentinel errors.
var conflict *auth.ErrOAuthEmailConflict
if errors.As(err, &conflict) {
return apiError{
Status: http.StatusConflict,
Code: "oauth_email_conflict",
Message: fmt.Sprintf("an account with this email already exists; log in with your password to link %s", conflict.Provider),
}
}
// Anything unmapped is treated as internal — deliberately
// vague to the client (never leak internal error strings,
// e.g. raw DB errors, over the API), logged server-side by
Expand Down
Loading
Loading