diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2d4bae1..b3fcf5e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: diff --git a/README.md b/README.md index aa5d69e..c639193 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 `. ## 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 diff --git a/config/config.go b/config/config.go index d9d266e..3d6f54e 100644 --- a/config/config.go +++ b/config/config.go @@ -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 @@ -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 } diff --git a/go.mod b/go.mod index 08cf439..1af7d56 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index cfbe649..dd355d6 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/httpapi/errors.go b/httpapi/errors.go index 8049821..4c90363 100644 --- a/httpapi/errors.go +++ b/httpapi/errors.go @@ -2,6 +2,7 @@ package httpapi import ( "errors" + "fmt" "net/http" "github.com/crydensync/cryden/v2/auth" @@ -34,12 +35,31 @@ var errMissingAuthHeader = errors.New("missing or malformed Authorization header // 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): @@ -65,7 +85,21 @@ func mapError(err error) apiError { 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 diff --git a/httpapi/oauth_handlers.go b/httpapi/oauth_handlers.go new file mode 100644 index 0000000..a20cd39 --- /dev/null +++ b/httpapi/oauth_handlers.go @@ -0,0 +1,517 @@ +package httpapi + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + + "github.com/crydensync/cryden/v2" + "github.com/crydensync/cryden/v2/auth" + + "github.com/crydensync/api/config" +) + +// oauthStateCookie is the name of the short-lived cookie holding the +// CSRF state value between the redirect and the callback. There's no +// session/cache store in this repo beyond Postgres, and a signed +// cookie is the standard, simplest fit for this exact problem — the +// value only needs to survive one browser round trip. +const oauthStateCookie = "cryden_oauth_state" + +// oauthLinkUserCookie carries the linking user's ID through the +// provider redirect round trip. A Bearer token in an Authorization +// header cannot survive a browser redirect to a provider and back — +// there is no header to carry there. So LinkStart (which DOES see the +// real Authorization header, since it's called directly by an +// authenticated client, not via a redirect) signs the user ID with +// HMAC-SHA256 using the same JWT secret and stashes it in this +// cookie; LinkCallback verifies the signature rather than trusting +// the plain value, so a tampered cookie can't be used to link +// someone else's account. +const oauthLinkUserCookie = "cryden_oauth_link_user" + +// oauthProvider is the minimal per-provider shape this handler needs. +// Each provider's actual endpoints/scopes are hardcoded below rather +// than made pluggable — adding a third provider means adding a third +// small case, not building a plugin system for two entries. +type oauthProvider struct { + name string + clientID string + clientSecret string + authURL string + tokenURL string + userInfoURL string + scope string +} + +type OAuthHandlers struct { + Engine *cryden.Engine + Config config.Config +} + +func (h *OAuthHandlers) provider(name string) (oauthProvider, bool) { + switch name { + case "google": + if h.Config.GoogleClientID == "" || h.Config.GoogleClientSecret == "" { + return oauthProvider{}, false + } + return oauthProvider{ + name: "google", + clientID: h.Config.GoogleClientID, + clientSecret: h.Config.GoogleClientSecret, + authURL: "https://accounts.google.com/o/oauth2/v2/auth", + tokenURL: "https://oauth2.googleapis.com/token", + userInfoURL: "https://www.googleapis.com/oauth2/v3/userinfo", + scope: "openid email", + }, true + case "github": + if h.Config.GitHubClientID == "" || h.Config.GitHubClientSecret == "" { + return oauthProvider{}, false + } + return oauthProvider{ + name: "github", + clientID: h.Config.GitHubClientID, + clientSecret: h.Config.GitHubClientSecret, + authURL: "https://github.com/login/oauth/authorize", + tokenURL: "https://github.com/login/oauth/access_token", + userInfoURL: "https://api.github.com/user", + scope: "read:user user:email", + }, true + default: + return oauthProvider{}, false + } +} + +func (h *OAuthHandlers) callbackURL(providerName string) string { + return h.Config.BaseURL + "/v1/oauth/" + providerName + "/callback" +} + +// Start redirects the browser to the provider's consent screen. GET, +// not POST — this is a full browser navigation, not an API call a +// JS client makes with fetch. +func (h *OAuthHandlers) Start(w http.ResponseWriter, r *http.Request, providerName string) { + p, ok := h.provider(providerName) + if !ok { + writeErr(w, errOAuthProviderNotConfigured) + return + } + + state, err := randomState() + if err != nil { + writeErr(w, err) + return + } + http.SetCookie(w, &http.Cookie{ + Name: oauthStateCookie, + Value: state, + Path: "/v1/oauth", + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteLaxMode, + MaxAge: 600, // 10 minutes — plenty for a consent-screen round trip + }) + + q := url.Values{ + "client_id": {p.clientID}, + "redirect_uri": {h.callbackURL(p.name)}, + "response_type": {"code"}, + "scope": {p.scope}, + "state": {state}, + } + http.Redirect(w, r, p.authURL+"?"+q.Encode(), http.StatusFound) +} + +// Callback receives the provider's redirect, exchanges the code, +// fetches the confirmed identity, and calls cryden.LoginWithOAuth. +// This is the ONLY place in this repo that talks to a provider's +// token/userinfo endpoints — by the time cryden.LoginWithOAuth is +// called, the identity is already confirmed; the engine itself never +// makes an HTTP call. +func (h *OAuthHandlers) Callback(w http.ResponseWriter, r *http.Request, providerName string) { + p, ok := h.provider(providerName) + if !ok { + writeErr(w, errOAuthProviderNotConfigured) + return + } + + if err := verifyState(r); err != nil { + writeErr(w, err) + return + } + clearStateCookie(w) + + code := r.URL.Query().Get("code") + if code == "" { + writeBadRequest(w, "missing code") + return + } + + externalID, email, err := exchangeAndFetchIdentity(r, p, h.callbackURL(p.name), code) + if err != nil { + writeErr(w, err) + return + } + + tokens, err := cryden.LoginWithOAuth(r.Context(), h.Engine, p.name, externalID, email, CallerIP(r), UserAgent(r)) + if err != nil { + var conflict *auth.ErrOAuthEmailConflict + if errors.As(err, &conflict) { + // The confirmed decision: never auto-link. Surface this + // as a distinct response the frontend routes to a + // dedicated "link your accounts" screen — not a generic + // login failure, and not silently resolved here. + writeErr(w, err) + return + } + writeErr(w, err) + return + } + writeData(w, http.StatusOK, toTokensDTO(tokens)) +} + +// LinkStart begins the linking flow for an ALREADY-AUTHENTICATED +// user — call this behind RequireAuth. It signs the caller's user ID +// into a short-lived cookie (see oauthLinkUserCookie) because that ID +// cannot otherwise survive the redirect to the provider and back. +func (h *OAuthHandlers) LinkStart(w http.ResponseWriter, r *http.Request, providerName string) { + p, ok := h.provider(providerName) + if !ok { + writeErr(w, errOAuthProviderNotConfigured) + return + } + + userID := UserIDFromContext(r) + signed, err := signLinkUserID(h.Config.JWTSecret, userID) + if err != nil { + writeErr(w, err) + return + } + http.SetCookie(w, &http.Cookie{ + Name: oauthLinkUserCookie, + Value: signed, + Path: "/v1/oauth", + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteLaxMode, + MaxAge: 600, + }) + + state, err := randomState() + if err != nil { + writeErr(w, err) + return + } + http.SetCookie(w, &http.Cookie{ + Name: oauthStateCookie, + Value: state, + Path: "/v1/oauth", + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteLaxMode, + MaxAge: 600, + }) + + q := url.Values{ + "client_id": {p.clientID}, + "redirect_uri": {h.linkCallbackURL(p.name)}, + "response_type": {"code"}, + "scope": {p.scope}, + "state": {state}, + } + http.Redirect(w, r, p.authURL+"?"+q.Encode(), http.StatusFound) +} + +// LinkCallback receives the provider's redirect for the linking flow. +// Deliberately NOT behind RequireAuth — there is no Authorization +// header on a browser redirect. Identity of the linking user instead +// comes from the signed cookie LinkStart set, verified here rather +// than trusted as plain text. +func (h *OAuthHandlers) LinkCallback(w http.ResponseWriter, r *http.Request, providerName string) { + p, ok := h.provider(providerName) + if !ok { + writeErr(w, errOAuthProviderNotConfigured) + return + } + + if err := verifyState(r); err != nil { + writeErr(w, err) + return + } + clearStateCookie(w) + + cookie, err := r.Cookie(oauthLinkUserCookie) + if err != nil { + writeErr(w, errOAuthLinkSessionMissing) + return + } + userID, err := verifyLinkUserID(h.Config.JWTSecret, cookie.Value) + if err != nil { + writeErr(w, errOAuthLinkSessionMissing) + return + } + clearLinkUserCookie(w) + + code := r.URL.Query().Get("code") + if code == "" { + writeBadRequest(w, "missing code") + return + } + + externalID, email, err := exchangeAndFetchIdentity(r, p, h.linkCallbackURL(p.name), code) + if err != nil { + writeErr(w, err) + return + } + + if err := cryden.LinkOAuthIdentity(r.Context(), h.Engine, userID, p.name, externalID, email, CallerIP(r)); err != nil { + writeErr(w, err) + return + } + writeData(w, http.StatusOK, map[string]string{"status": "linked", "provider": p.name}) +} + +func (h *OAuthHandlers) linkCallbackURL(providerName string) string { + return h.Config.BaseURL + "/v1/oauth/" + providerName + "/link/callback" +} + +// signLinkUserID and verifyLinkUserID are a minimal HMAC-SHA256 +// sign/verify pair — not a JWT, deliberately simpler, since this only +// ever needs to survive one short redirect round trip, not be a +// general-purpose bearer credential. +func signLinkUserID(secret, userID string) (string, error) { + if secret == "" { + return "", errOAuthLinkNotConfigured + } + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(userID)) + sig := base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) + return base64.RawURLEncoding.EncodeToString([]byte(userID)) + "." + sig, nil +} + +func verifyLinkUserID(secret, value string) (string, error) { + if secret == "" { + return "", errOAuthLinkNotConfigured + } + parts := splitOnce(value, '.') + if len(parts) != 2 { + return "", errOAuthLinkSessionMissing + } + userIDBytes, err := base64.RawURLEncoding.DecodeString(parts[0]) + if err != nil { + return "", errOAuthLinkSessionMissing + } + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write(userIDBytes) + expectedSig := base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) + if !hmac.Equal([]byte(expectedSig), []byte(parts[1])) { + return "", errOAuthLinkSessionMissing + } + return string(userIDBytes), nil +} + +func splitOnce(s string, sep byte) []string { + for i := 0; i < len(s); i++ { + if s[i] == sep { + return []string{s[:i], s[i+1:]} + } + } + return []string{s} +} + +func clearLinkUserCookie(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{ + Name: oauthLinkUserCookie, + Value: "", + Path: "/v1/oauth", + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteLaxMode, + MaxAge: -1, + }) +} + +// fetchGitHubPrimaryEmail calls GET /user/emails and returns the +// primary, verified address. GitHub can return multiple emails +// (work, personal, noreply aliases) — only the primary+verified one +// is trustworthy enough to use as the account's identity. +func fetchGitHubPrimaryEmail(r *http.Request, providerToken string) (string, error) { + req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, "https://api.github.com/user/emails", nil) + if err != nil { + return "", err + } + req.Header.Set("Authorization", "Bearer "+providerToken) + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("httpapi: github /user/emails request failed: status %d", resp.StatusCode) + } + + var emails []struct { + Email string `json:"email"` + Primary bool `json:"primary"` + Verified bool `json:"verified"` + } + if err := json.Unmarshal(body, &emails); err != nil { + return "", err + } + for _, e := range emails { + if e.Primary && e.Verified { + return e.Email, nil + } + } + return "", errOAuthEmailNotAvailable +} + +func randomState() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(b), nil +} + +func verifyState(r *http.Request) error { + cookie, err := r.Cookie(oauthStateCookie) + if err != nil || cookie.Value == "" { + return errOAuthStateMismatch + } + if r.URL.Query().Get("state") != cookie.Value { + return errOAuthStateMismatch + } + return nil +} + +func clearStateCookie(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{ + Name: oauthStateCookie, + Value: "", + Path: "/v1/oauth", + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteLaxMode, + MaxAge: -1, + }) +} + +// exchangeAndFetchIdentity does the actual provider protocol work: +// exchange the code for a provider access token, then use that token +// to fetch the confirmed external ID and email. Deliberately the only +// function in this file that reaches out to a provider — everything +// above it is either request-shaped (redirect/state) or calls into +// the engine. +func exchangeAndFetchIdentity(r *http.Request, p oauthProvider, redirectURI, code string) (externalID, email string, err error) { + tokenResp, err := exchangeCode(r, p, redirectURI, code) + if err != nil { + return "", "", err + } + + req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, p.userInfoURL, nil) + if err != nil { + return "", "", err + } + req.Header.Set("Authorization", "Bearer "+tokenResp) + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", "", err + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", "", err + } + if resp.StatusCode != http.StatusOK { + return "", "", fmt.Errorf("httpapi: oauth userinfo request failed: status %d", resp.StatusCode) + } + + switch p.name { + case "google": + var info struct { + Sub string `json:"sub"` + Email string `json:"email"` + } + if err := json.Unmarshal(body, &info); err != nil { + return "", "", err + } + return info.Sub, info.Email, nil + case "github": + var info struct { + ID int64 `json:"id"` + Email string `json:"email"` + } + if err := json.Unmarshal(body, &info); err != nil { + return "", "", err + } + if info.Email == "" { + // GitHub's /user endpoint only returns email if the + // account has made it public. The verified primary + // address instead comes from /user/emails, which needs + // the same token and the same scope this handler already + // requests (user:email). + email, err := fetchGitHubPrimaryEmail(r, tokenResp) + if err != nil { + return "", "", err + } + return fmt.Sprintf("%d", info.ID), email, nil + } + return fmt.Sprintf("%d", info.ID), info.Email, nil + default: + return "", "", errOAuthProviderNotConfigured + } +} + +// exchangeCode trades the authorization code for a provider access +// token. Returns just the token string — this handler only ever needs +// it to make the one immediate userinfo call, never stores it. +func exchangeCode(r *http.Request, p oauthProvider, redirectURI, code string) (string, error) { + form := url.Values{ + "client_id": {p.clientID}, + "client_secret": {p.clientSecret}, + "code": {code}, + "redirect_uri": {redirectURI}, + "grant_type": {"authorization_code"}, + } + req, err := http.NewRequestWithContext(r.Context(), http.MethodPost, p.tokenURL, nil) + if err != nil { + return "", err + } + req.URL.RawQuery = form.Encode() // both providers accept this as query or form body; query keeps this dependency-free + req.Header.Set("Accept", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("httpapi: oauth token exchange failed: status %d", resp.StatusCode) + } + + var tokenResp struct { + AccessToken string `json:"access_token"` + } + if err := json.Unmarshal(body, &tokenResp); err != nil { + return "", err + } + if tokenResp.AccessToken == "" { + return "", fmt.Errorf("httpapi: oauth token exchange returned no access_token") + } + return tokenResp.AccessToken, nil +} diff --git a/httpapi/router.go b/httpapi/router.go index e0abe78..1a23d1f 100644 --- a/httpapi/router.go +++ b/httpapi/router.go @@ -5,15 +5,18 @@ import ( "net/http" "github.com/crydensync/cryden/v2" + + "github.com/crydensync/api/config" ) // NewRouter builds the full route table. Called once from main.go. -func NewRouter(engine *cryden.Engine, db *sql.DB) http.Handler { +func NewRouter(engine *cryden.Engine, db *sql.DB, cfg config.Config) http.Handler { auth := &AuthHandlers{Engine: engine} sessions := &SessionHandlers{Engine: engine} account := &AccountHandlers{Engine: engine} email := &EmailHandlers{Engine: engine} health := &HealthHandler{DB: db} + oauth := &OAuthHandlers{Engine: engine, Config: cfg} mux := http.NewServeMux() @@ -24,6 +27,26 @@ func NewRouter(engine *cryden.Engine, db *sql.DB) http.Handler { mux.HandleFunc("POST /v1/email/confirm-change", email.ConfirmChange) mux.HandleFunc("GET /v1/health", health.Health) + // OAuth — Start and Callback are public (they're the login/signup + // path itself, same as /v1/login). Link requires auth since it + // attaches an identity to an already-authenticated user. + mux.HandleFunc("GET /v1/oauth/{provider}", func(w http.ResponseWriter, r *http.Request) { + oauth.Start(w, r, r.PathValue("provider")) + }) + mux.HandleFunc("GET /v1/oauth/{provider}/callback", func(w http.ResponseWriter, r *http.Request) { + oauth.Callback(w, r, r.PathValue("provider")) + }) + mux.HandleFunc("GET /v1/oauth/{provider}/link", RequireAuth(engine, func(w http.ResponseWriter, r *http.Request) { + oauth.LinkStart(w, r, r.PathValue("provider")) + })) + // NOT behind RequireAuth — a browser redirect from the provider + // carries no Authorization header. The linking user's identity + // instead comes from the signed cookie LinkStart set; see + // oauth_handlers.go's LinkCallback for why. + mux.HandleFunc("GET /v1/oauth/{provider}/link/callback", func(w http.ResponseWriter, r *http.Request) { + oauth.LinkCallback(w, r, r.PathValue("provider")) + }) + // Authenticated mux.HandleFunc("POST /v1/logout", RequireAuth(engine, auth.Logout)) mux.HandleFunc("POST /v1/logout-all", RequireAuth(engine, auth.LogoutAll)) diff --git a/internal/smoketest/main.go b/internal/smoketest/main.go index 5073426..585e23c 100644 --- a/internal/smoketest/main.go +++ b/internal/smoketest/main.go @@ -168,6 +168,35 @@ func main() { return nil }) + check("oauth start for an unconfigured provider is rejected, not silently allowed", func() error { + // This deployment's env almost certainly doesn't have real + // Google/GitHub client credentials set, which is exactly the + // case this checks: an unconfigured provider must fail + // cleanly, not redirect somewhere broken. A provider name + // that was never going to exist proves the same thing either + // way. + var resp map[string]any + status, _ := doJSON("GET", "/v1/oauth/not-a-real-provider", nil, "", &resp) + if status != 404 { + return fmt.Errorf("expected 404 for an unconfigured/unknown provider, got %d", status) + } + return nil + }) + + check("oauth link without auth is rejected", func() error { + // The whole reason LinkStart/LinkCallback are split from + // Start/Callback: linking must only ever happen for an + // authenticated caller. This is the one piece of that + // guarantee this smoke test can verify without a real + // browser round trip to a provider. + var resp map[string]any + status, _ := doJSON("GET", "/v1/oauth/google/link", nil, "", &resp) + if status != 401 { + return fmt.Errorf("expected 401 for an unauthenticated link attempt, got %d", status) + } + return nil + }) + fmt.Println("\nALL CHECKS PASSED") } diff --git a/main.go b/main.go index 833eece..161e8bc 100644 --- a/main.go +++ b/main.go @@ -37,12 +37,13 @@ func main() { Verifications: postgres.NewVerificationStore(db), EmailSender: &consoleEmailSender{}, // dev stand-in — see email_sender.go AccessTokenTTL: cfg.AccessTokenTTL, + OAuth: postgres.NewOAuthStore(db), }) if err != nil { log.Fatalf("failed to construct cryden engine: %v", err) } - router := httpapi.NewRouter(engine, db) + router := httpapi.NewRouter(engine, db, cfg) limiter := httpapi.NewEdgeRateLimiter(cfg.EdgeRateLimit, cfg.EdgeRateLimitWindow) handler := httpapi.WithCORS(cfg.CORSOrigins, httpapi.WithEdgeRateLimit(limiter, router)) diff --git a/migrations/002_oauth_identities.down.sql b/migrations/002_oauth_identities.down.sql new file mode 100644 index 0000000..e2eec80 --- /dev/null +++ b/migrations/002_oauth_identities.down.sql @@ -0,0 +1,3 @@ +-- 0002_oauth_identities.down.sql + +DROP TABLE oauth_identities; diff --git a/migrations/002_oauth_identities.up.sql b/migrations/002_oauth_identities.up.sql new file mode 100644 index 0000000..a433f58 --- /dev/null +++ b/migrations/002_oauth_identities.up.sql @@ -0,0 +1,15 @@ +-- 0002_oauth_identities.up.sql + +CREATE TABLE oauth_identities ( + id UUID PRIMARY KEY, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + provider TEXT NOT NULL, + external_id TEXT NOT NULL, + email TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + -- Backstops OAuthStore.GetByProviderID and is the real guard + -- against ever double-linking the same external account. + UNIQUE (provider, external_id) +); + +CREATE INDEX idx_oauth_identities_user_id ON oauth_identities(user_id);