diff --git a/.env.example b/.env.example index ea85214..b32c627 100644 --- a/.env.example +++ b/.env.example @@ -53,3 +53,8 @@ COOKIE_SECURE=false # Frontend (CORS + email link base) FRONTEND_URL=http://localhost:3000 + +GOOGLE_OAUTH_CLIENT_ID= +GOOGLE_OAUTH_CLIENT_SECRET= +FACEBOOK_OAUTH_CLIENT_ID= +FACEBOOK_OAUTH_CLIENT_SECRET= \ No newline at end of file diff --git a/go.mod b/go.mod index 49a7f07..640c1a7 100644 --- a/go.mod +++ b/go.mod @@ -3,23 +3,26 @@ module github.com/prabhupadasays/spsays-bff go 1.25.0 require ( + github.com/aws/aws-sdk-go-v2 v1.41.9 + github.com/aws/aws-sdk-go-v2/credentials v1.19.19 + github.com/aws/aws-sdk-go-v2/service/s3 v1.102.2 github.com/go-chi/chi/v5 v5.2.1 github.com/go-chi/cors v1.2.1 + github.com/golang-jwt/jwt/v5 v5.3.1 github.com/jackc/pgx/v5 v5.9.2 github.com/joho/godotenv v1.5.1 github.com/redis/go-redis/v9 v9.19.0 github.com/typesense/typesense-go/v2 v2.0.0 go.mongodb.org/mongo-driver/v2 v2.2.1 go.uber.org/zap v1.27.0 + golang.org/x/crypto v0.52.0 + golang.org/x/oauth2 v0.36.0 ) require ( + cloud.google.com/go/compute/metadata v0.3.0 // indirect github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect - github.com/aws/aws-sdk-go-v2 v1.41.9 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.11 // indirect - github.com/aws/aws-sdk-go-v2/config v1.32.20 // indirect - github.com/aws/aws-sdk-go-v2/credentials v1.19.19 // indirect - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.25 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.25 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.25 // indirect github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.26 // indirect @@ -27,14 +30,8 @@ require ( github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.18 // indirect github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.25 // indirect github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.25 // indirect - github.com/aws/aws-sdk-go-v2/service/s3 v1.102.2 // indirect - github.com/aws/aws-sdk-go-v2/service/signin v1.1.1 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.30.19 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.2 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.42.3 // indirect github.com/aws/smithy-go v1.26.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/golang/snappy v1.0.0 // indirect github.com/google/uuid v1.5.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect @@ -49,7 +46,6 @@ require ( github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect go.uber.org/atomic v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect - golang.org/x/crypto v0.52.0 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/text v0.37.0 // indirect ) diff --git a/go.sum b/go.sum index 27fb4c0..b503c2e 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc= +cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= @@ -5,12 +7,8 @@ github.com/aws/aws-sdk-go-v2 v1.41.9 h1:/rYeyO2+HrMztAmxAq9++XJtFMqSIpSsNA0yDGAL github.com/aws/aws-sdk-go-v2 v1.41.9/go.mod h1:+HsoOEX80qAVUitj1A2DhCNTjmb3edVyuDypb6LNEeo= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.11 h1:h5+3VT69KUBK24grGuuA5saDJTj2IIjLb9au668Fo5I= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.11/go.mod h1:dnakxebH6UwFvcvujL0LVggYQ8nEvBGjU4G/V79Nv94= -github.com/aws/aws-sdk-go-v2/config v1.32.20 h1:8VMDnWc/kEzxsI/1ngGM9mG81a8IGmIHD8KLcYGwagc= -github.com/aws/aws-sdk-go-v2/config v1.32.20/go.mod h1:PuwEpciweIXGULWeOeSTXtSbH4CW9mWdWrhdCKQI1sM= github.com/aws/aws-sdk-go-v2/credentials v1.19.19 h1:yuFzSV1U0aRNYCQGVaTY2zW2M/L93pYHnXnrJUphYhU= github.com/aws/aws-sdk-go-v2/credentials v1.19.19/go.mod h1:7y63L1kGzeoDlJaQ3Z578KrnmfBut96JjvJUzGwR+YE= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.25 h1:0w6dCiO8iez+YKwRhRBlL1CH/E3GTfdkuzrwj1by8vo= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.25/go.mod h1:9FDWUothyr5RCRAHc45XOiVCzUR8n/IhCYX+uVqw6vk= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.25 h1:Uii3frf9ztec/ABM2/FSH9/z7PLzxfpG8h4RpkUFflQ= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.25/go.mod h1:G6kntsA2GorAxDPbap6xgB2F+amSLUF8GJTi7PUoX44= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.25 h1:r1+/l6m+WaUJF9HISEsNOLHSNj5EXYQxK8VX6Cz9NlA= @@ -27,14 +25,6 @@ github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.25 h1:2pQEbwf+/6EDb github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.25/go.mod h1:KvT6NCcQ0EZ+ZkVRrlBMt04Po3ok23YELEp7WimhLhM= github.com/aws/aws-sdk-go-v2/service/s3 v1.102.2 h1:ie4ElCmUKS26pzrZcIk/lmt4yWjAqLLcawstyQCh298= github.com/aws/aws-sdk-go-v2/service/s3 v1.102.2/go.mod h1:zjsomFeX5duj+4PlMB+o4JoWTIx+G0XMyzjYrUbQkN0= -github.com/aws/aws-sdk-go-v2/service/signin v1.1.1 h1:1VwbP3qMNfxUDEXWki4rCE5iA+44VA1lokTz9HasGzw= -github.com/aws/aws-sdk-go-v2/service/signin v1.1.1/go.mod h1:vUtyoSj0OPji3kjIVSc/GlKuWEiL33f/WFxl6dmpy/A= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.19 h1:N6pIsdFOW1Kd9S4KyFKXdGRBojPPxkP32+uHFWLv4Hc= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.19/go.mod h1:3gt5WJArFooNmyLONS+h/R4J+o86II8du38IgCwj9dE= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.2 h1:hc+lBYiiTr8Zk4MTzIsQ92MeDWCIDvWGmzKUWOaBcOg= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.2/go.mod h1:hU6fqB3OJA6/ePheD47LQnxvjYk6br6PtQxs+Q9ojvk= -github.com/aws/aws-sdk-go-v2/service/sts v1.42.3 h1:ErklX/7uhSbkAAeyQD/Y1OoQ9hO3SJXQNEgksORW3Js= -github.com/aws/aws-sdk-go-v2/service/sts v1.42.3/go.mod h1:ULe4HCzfKPiR6R3HEurE3b1upEkuk8AkMrOKtaOxKO8= github.com/aws/smithy-go v1.26.0 h1:9ouqbi+NyKP7fV3Te7UElCwdAb6Y8uk7LGwPE5tVe/s= github.com/aws/smithy-go v1.26.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= @@ -117,18 +107,16 @@ go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= -golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= -golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -136,17 +124,14 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= -golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= -golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= -golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/internal/config/config.go b/internal/config/config.go index 55ab56d..bacc10b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -65,6 +65,18 @@ type Config struct { // Frontend FrontendURL string // for CORS + reset/verify links in emails + + // BFF public URL — the externally-reachable origin of this server. Used + // to build the OAuth redirect_uri the provider sees (must match the + // allow-list configured in the provider's console). In dev this is + // usually http://localhost:8080. + BFFPublicURL string + + // OAuth providers — empty creds disable the corresponding provider. + GoogleOAuthClientID string + GoogleOAuthClientSecret string + FacebookOAuthClientID string + FacebookOAuthClientSecret string } // Load reads configuration from environment variables (with .env fallback). @@ -116,6 +128,13 @@ func Load() (*Config, error) { CookieSecure: getEnvBool("COOKIE_SECURE", false), FrontendURL: getEnv("FRONTEND_URL", "http://localhost:3000"), + + BFFPublicURL: getEnv("BFF_PUBLIC_URL", "http://localhost:8080"), + + GoogleOAuthClientID: getEnv("GOOGLE_OAUTH_CLIENT_ID", ""), + GoogleOAuthClientSecret: getEnv("GOOGLE_OAUTH_CLIENT_SECRET", ""), + FacebookOAuthClientID: getEnv("FACEBOOK_OAUTH_CLIENT_ID", ""), + FacebookOAuthClientSecret: getEnv("FACEBOOK_OAUTH_CLIENT_SECRET", ""), } if cfg.MongoURI == "" { diff --git a/internal/handler/oauth.go b/internal/handler/oauth.go new file mode 100644 index 0000000..1fc8fbf --- /dev/null +++ b/internal/handler/oauth.go @@ -0,0 +1,219 @@ +package handler + +import ( + "errors" + "net/http" + "net/url" + "strings" + "time" + + "github.com/go-chi/chi/v5" + "github.com/prabhupadasays/spsays-bff/internal/service" + "github.com/prabhupadasays/spsays-bff/pkg/auth" + "github.com/prabhupadasays/spsays-bff/pkg/oauth" + "go.uber.org/zap" +) + +// stateCookieName is the short-lived cookie that pairs with the state query +// param so callback can verify "this redirect was started by us." Scoped to +// /api/v1/auth/oauth so it never leaks into other handlers. +const stateCookieName = "spsays_oauth_state" +const stateCookieMaxAge = 10 * 60 // 10 minutes — matches stateMaxAge in pkg/oauth/state.go + +// OAuthHandler exposes the GET /api/v1/auth/oauth/{provider}/start and +// /callback endpoints. It composes Provider → token exchange → service.SignInWithOAuth. +type OAuthHandler struct { + svc *service.AuthService + cookies auth.CookieOptions + providers oauth.Registry + stateSecret []byte // HMAC key for signing state — derived from JWT secret + bffPublicURL string // e.g. http://localhost:8080 or https://api.prabhupadasays.org + frontendURL string // e.g. http://localhost:3000 or https://prabhupadasays.org + logger *zap.Logger +} + +func NewOAuthHandler( + svc *service.AuthService, + cookies auth.CookieOptions, + providers oauth.Registry, + stateSecret []byte, + bffPublicURL, frontendURL string, + logger *zap.Logger, +) *OAuthHandler { + return &OAuthHandler{ + svc: svc, + cookies: cookies, + providers: providers, + stateSecret: stateSecret, + bffPublicURL: strings.TrimRight(bffPublicURL, "/"), + frontendURL: strings.TrimRight(frontendURL, "/"), + logger: logger, + } +} + +// Start kicks off the OAuth round-trip. It generates a signed state value, +// stores it in a short-lived cookie, and 302s the browser to the provider's +// AuthCodeURL. `next` query param carries the post-login redirect path on +// the frontend (defaults to "/u/me"). +func (h *OAuthHandler) Start(w http.ResponseWriter, r *http.Request) { + providerName := chi.URLParam(r, "provider") + prov, err := h.providers.Get(providerName) + if err != nil { + http.Error(w, "unknown provider", http.StatusNotFound) + return + } + + next := sanitizeNext(r.URL.Query().Get("next")) + + state, err := oauth.EncodeState(h.stateSecret, providerName, next) + if err != nil { + h.logger.Error("oauth encode state", zap.Error(err)) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + // State cookie matches the auth cookie's SameSite/Secure profile so the + // browser sends it back on the callback redirect. Path-scoped so it + // doesn't ride along on unrelated requests. + http.SetCookie(w, &http.Cookie{ + Name: stateCookieName, + Value: state, + Path: "/api/v1/auth/oauth", + Domain: h.cookies.Domain, + MaxAge: stateCookieMaxAge, + HttpOnly: true, + Secure: h.cookies.Secure, + SameSite: http.SameSiteLaxMode, + }) + + cfg := prov.Config(h.callbackURL(providerName)) + authURL := cfg.AuthCodeURL(state) + http.Redirect(w, r, authURL, http.StatusFound) +} + +// Callback finishes the round-trip. Verifies state, exchanges the code, +// fetches the profile, signs the user in, sets the session cookie, and +// redirects back to the frontend `next` path. +func (h *OAuthHandler) Callback(w http.ResponseWriter, r *http.Request) { + providerName := chi.URLParam(r, "provider") + prov, err := h.providers.Get(providerName) + if err != nil { + h.redirectErr(w, r, "/login?oauth_error=unknown_provider") + return + } + + // Provider returned an error (user denied, etc.). + if e := r.URL.Query().Get("error"); e != "" { + h.redirectErr(w, r, "/login?oauth_error="+url.QueryEscape(e)) + return + } + + // State must be present in BOTH cookie and query param, and they must + // match. Either alone is not sufficient (cookie alone → no CSRF binding; + // query alone → an attacker could craft any signed state). + cookie, err := r.Cookie(stateCookieName) + if err != nil { + h.redirectErr(w, r, "/login?oauth_error=missing_state") + return + } + stateQ := r.URL.Query().Get("state") + if stateQ == "" || stateQ != cookie.Value { + h.redirectErr(w, r, "/login?oauth_error=invalid_state") + return + } + // Clear the state cookie regardless of what happens next. + http.SetCookie(w, &http.Cookie{ + Name: stateCookieName, + Value: "", + Path: "/api/v1/auth/oauth", + Domain: h.cookies.Domain, + MaxAge: -1, + Expires: time.Unix(0, 0), + HttpOnly: true, + Secure: h.cookies.Secure, + SameSite: http.SameSiteLaxMode, + }) + + stateProvider, next, err := oauth.DecodeState(h.stateSecret, stateQ) + if err != nil || stateProvider != providerName { + h.redirectErr(w, r, "/login?oauth_error=invalid_state") + return + } + + code := r.URL.Query().Get("code") + if code == "" { + h.redirectErr(w, r, "/login?oauth_error=missing_code") + return + } + + cfg := prov.Config(h.callbackURL(providerName)) + tok, err := cfg.Exchange(r.Context(), code) + if err != nil { + h.logger.Warn("oauth code exchange failed", zap.String("provider", providerName), zap.Error(err)) + h.redirectErr(w, r, "/login?oauth_error=exchange_failed") + return + } + + prof, err := prov.FetchProfile(r.Context(), tok) + if err != nil { + h.logger.Warn("oauth fetch profile failed", zap.String("provider", providerName), zap.Error(err)) + h.redirectErr(w, r, "/login?oauth_error=profile_failed") + return + } + + res, err := h.svc.SignInWithOAuth(r.Context(), service.OAuthProfile{ + Provider: prof.Provider, + ProviderUserID: prof.ProviderUserID, + Email: prof.Email, + EmailVerified: prof.EmailVerified, + Name: prof.Name, + AvatarURL: prof.AvatarURL, + }) + if err != nil { + switch { + case errors.Is(err, service.ErrOAuthEmailMissing): + h.redirectErr(w, r, "/login?oauth_error=email_required") + case errors.Is(err, service.ErrOAuthUnavailable): + h.redirectErr(w, r, "/login?oauth_error=unavailable") + default: + h.logger.Error("oauth sign-in", zap.Error(err)) + h.redirectErr(w, r, "/login?oauth_error=server") + } + return + } + + auth.Set(w, res.Token, res.Expires, h.cookies) + http.Redirect(w, r, h.frontendURL+next, http.StatusFound) +} + +// callbackURL composes the redirect_uri the provider's console must allow-list. +func (h *OAuthHandler) callbackURL(provider string) string { + return h.bffPublicURL + "/api/v1/auth/oauth/" + provider + "/callback" +} + +// redirectErr sends the user back to the frontend with a single error query +// param so the login page can render something meaningful. No internal details +// leak through this path. +func (h *OAuthHandler) redirectErr(w http.ResponseWriter, r *http.Request, path string) { + http.Redirect(w, r, h.frontendURL+path, http.StatusFound) +} + +// sanitizeNext bounds the post-login redirect to same-origin paths. Anything +// containing a scheme, host, or whitespace is dropped to the default. This is +// the same anti-open-redirect treatment we apply on the share-shortener. +func sanitizeNext(raw string) string { + const def = "/u/me" + if raw == "" { + return def + } + if !strings.HasPrefix(raw, "/") || strings.HasPrefix(raw, "//") { + return def + } + if strings.ContainsAny(raw, " \t\r\n") { + return def + } + if len(raw) > 512 { + return def + } + return raw +} diff --git a/internal/model/user.go b/internal/model/user.go index 3acbccd..6555081 100644 --- a/internal/model/user.go +++ b/internal/model/user.go @@ -5,7 +5,9 @@ import "time" // User represents a row in the `users` table. // // PasswordHash is tagged `json:"-"` so it never leaks via JSON marshalling -// even if a caller accidentally passes a User through response.JSON. +// even if a caller accidentally passes a User through response.JSON. It's a +// pointer because OAuth-only users have no password — *PasswordHash == nil +// means "this account can only sign in via a linked OAuth provider". // // EmailVerifiedAt is a pointer so JSON-encoded users carry an explicit `null` // when the user has not yet verified, instead of an omitted field — the @@ -19,7 +21,7 @@ type User struct { Role string `json:"role"` Plan string `json:"plan"` // "free" | "premium" EmailVerifiedAt *time.Time `json:"email_verified_at"` - PasswordHash string `json:"-"` + PasswordHash *string `json:"-"` TokenVersion int `json:"-"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` @@ -42,6 +44,21 @@ const ( PurposeEmailVerification = "email_verification" ) +// OAuthAccount represents a row in the `oauth_accounts` table — one entry +// per (provider, provider_user_id). A single user can have several linked +// providers. +type OAuthAccount struct { + ID string `json:"id"` + UserID string `json:"user_id"` + Provider string `json:"provider"` + ProviderUserID string `json:"provider_user_id"` + Email *string `json:"email"` + Name *string `json:"name"` + AvatarURL *string `json:"avatar_url"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + // ShortURL represents a shortened URL. type ShortURL struct { ID string `json:"id"` diff --git a/internal/repository/oauth_account_repo.go b/internal/repository/oauth_account_repo.go new file mode 100644 index 0000000..bdf7d47 --- /dev/null +++ b/internal/repository/oauth_account_repo.go @@ -0,0 +1,69 @@ +package repository + +import ( + "context" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + "github.com/prabhupadasays/spsays-bff/internal/model" +) + +// ErrOAuthAccountNotFound mirrors the other repo sentinels. +var ErrOAuthAccountNotFound = errors.New("oauth account not found") + +// OAuthAccountRepo manages provider linkage rows. +type OAuthAccountRepo struct { + pg *Postgres +} + +func NewOAuthAccountRepo(pg *Postgres) *OAuthAccountRepo { + return &OAuthAccountRepo{pg: pg} +} + +// FindByProvider returns the linkage row for the given (provider, provider_user_id), +// or ErrOAuthAccountNotFound. Use this first on every callback — a hit means +// this provider identity has been seen before and we just log the user in. +func (r *OAuthAccountRepo) FindByProvider(ctx context.Context, provider, providerUserID string) (*model.OAuthAccount, error) { + const q = ` + SELECT id, user_id, provider, provider_user_id, email, name, avatar_url, created_at, updated_at + FROM oauth_accounts + WHERE provider = $1 AND provider_user_id = $2 + LIMIT 1` + a := &model.OAuthAccount{} + err := r.pg.Pool.QueryRow(ctx, q, provider, providerUserID).Scan( + &a.ID, &a.UserID, &a.Provider, &a.ProviderUserID, + &a.Email, &a.Name, &a.AvatarURL, &a.CreatedAt, &a.UpdatedAt, + ) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrOAuthAccountNotFound + } + return nil, fmt.Errorf("find oauth account: %w", err) + } + return a, nil +} + +// Link inserts a new oauth_accounts row tying a provider identity to an +// existing user. Idempotent: a second call for the same (provider, sub) +// returns the existing row instead of erroring on the unique constraint. +func (r *OAuthAccountRepo) Link(ctx context.Context, userID, provider, providerUserID string, email, name, avatarURL *string) (*model.OAuthAccount, error) { + const q = ` + INSERT INTO oauth_accounts (user_id, provider, provider_user_id, email, name, avatar_url) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (provider, provider_user_id) DO UPDATE + SET email = EXCLUDED.email, + name = EXCLUDED.name, + avatar_url = EXCLUDED.avatar_url, + updated_at = now() + RETURNING id, user_id, provider, provider_user_id, email, name, avatar_url, created_at, updated_at` + a := &model.OAuthAccount{} + err := r.pg.Pool.QueryRow(ctx, q, userID, provider, providerUserID, email, name, avatarURL).Scan( + &a.ID, &a.UserID, &a.Provider, &a.ProviderUserID, + &a.Email, &a.Name, &a.AvatarURL, &a.CreatedAt, &a.UpdatedAt, + ) + if err != nil { + return nil, fmt.Errorf("link oauth account: %w", err) + } + return a, nil +} diff --git a/internal/repository/user_repo.go b/internal/repository/user_repo.go index b4c7acc..c756f92 100644 --- a/internal/repository/user_repo.go +++ b/internal/repository/user_repo.go @@ -62,6 +62,32 @@ func (r *UserRepo) FindByEmail(ctx context.Context, email string) (*model.User, `WHERE email = $1`, strings.ToLower(strings.TrimSpace(email))) } +// CreateOAuth inserts a user with a NULL password_hash — used by the OAuth +// sign-in flow when there's no matching email yet. Same handle-collision +// retry contract as Create. +func (r *UserRepo) CreateOAuth(ctx context.Context, email, name, handle string, avatarURL *string, emailVerified bool) (*model.User, error) { + const q = ` + INSERT INTO users (email, password_hash, name, handle, avatar_url, email_verified_at) + VALUES ($1, NULL, $2, $3, $4, CASE WHEN $5::boolean THEN now() ELSE NULL END) + RETURNING id, email, handle, password_hash, name, avatar_url, role, plan, + email_verified_at, token_version, created_at, updated_at` + u := &model.User{} + err := r.pg.Pool.QueryRow(ctx, q, + strings.ToLower(strings.TrimSpace(email)), name, handle, avatarURL, emailVerified, + ).Scan(&u.ID, &u.Email, &u.Handle, &u.PasswordHash, &u.Name, &u.AvatarURL, &u.Role, &u.Plan, + &u.EmailVerifiedAt, &u.TokenVersion, &u.CreatedAt, &u.UpdatedAt) + if err != nil { + if isUniqueViolation(err, "users_handle_key") { + return nil, ErrHandleTaken + } + if isUniqueViolation(err, "users_email_key") { + return nil, ErrEmailTaken + } + return nil, fmt.Errorf("create oauth user: %w", err) + } + return u, nil +} + // FindByID returns the user with the given id, or ErrUserNotFound. func (r *UserRepo) FindByID(ctx context.Context, id string) (*model.User, error) { return r.findOne(ctx, `WHERE id = $1`, id) diff --git a/internal/server/server.go b/internal/server/server.go index 16aed2a..d00961c 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -16,6 +16,7 @@ import ( "github.com/prabhupadasays/spsays-bff/internal/worker" "github.com/prabhupadasays/spsays-bff/pkg/auth" "github.com/prabhupadasays/spsays-bff/pkg/mailer" + "github.com/prabhupadasays/spsays-bff/pkg/oauth" "github.com/prabhupadasays/spsays-bff/pkg/shorturl" "go.uber.org/zap" ) @@ -45,6 +46,7 @@ type Server struct { searchH *handler.SearchHandler shortURLH *handler.ShortURLHandler authH *handler.AuthHandler + oauthH *handler.OAuthHandler userH *handler.UserHandler postH *handler.PostHandler bookmarkH *handler.BookmarkHandler @@ -121,6 +123,7 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) { userRepo := repository.NewUserRepo(s.pg) tokenRepo := repository.NewAuthTokenRepo(s.pg) + oauthRepo := repository.NewOAuthAccountRepo(s.pg) r2, err := repository.NewR2(repository.R2Config{ AccountID: cfg.R2AccountID, @@ -145,7 +148,7 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) { From: cfg.SMTPFrom, }, logger) - authSvc := service.NewAuthService(userRepo, tokenRepo, issuer, mail, logger, cfg.FrontendURL) + authSvc := service.NewAuthService(userRepo, tokenRepo, oauthRepo, issuer, mail, logger, cfg.FrontendURL) userSvc := service.NewUserService(userRepo, r2) // Social repositories + services share the same Postgres pool and R2 client. @@ -162,6 +165,25 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) { s.userRepo = userRepo s.authH = handler.NewAuthHandler(authSvc, cookieOpts) s.userH = handler.NewUserHandler(userSvc) + + // OAuth — wired only when at least one provider is configured. The + // registry skips providers with missing creds, so partial configs (e.g. + // Google only, no Facebook) work out of the box. + providers := oauth.NewRegistry(oauth.Credentials{ + GoogleClientID: cfg.GoogleOAuthClientID, + GoogleClientSecret: cfg.GoogleOAuthClientSecret, + FacebookClientID: cfg.FacebookOAuthClientID, + FacebookClientSecret: cfg.FacebookOAuthClientSecret, + }) + if len(providers) > 0 { + s.oauthH = handler.NewOAuthHandler( + authSvc, cookieOpts, providers, + []byte(cfg.JWTSecret), cfg.BFFPublicURL, cfg.FrontendURL, + logger, + ) + } else { + logger.Info("oauth: no providers configured — /auth/oauth/* will 404") + } s.postH = handler.NewPostHandler(postSvc) s.bookmarkH = handler.NewBookmarkHandler(bookmarkSvc) s.collectionH = handler.NewCollectionHandler(collectionSvc, postRepo, issuer, userRepo) @@ -486,6 +508,15 @@ func (s *Server) setupRouter() { r.Post("/reset-password", s.authH.ResetPassword) r.Post("/verify-email", s.authH.VerifyEmail) + // OAuth (Google, Facebook, …). GET endpoints because the browser + // navigates directly to /start (top-level redirect) and the + // provider redirects back to /callback. Both flows set cookies + // and 302 — they never return JSON. + if s.oauthH != nil { + r.Get("/oauth/{provider}/start", s.oauthH.Start) + r.Get("/oauth/{provider}/callback", s.oauthH.Callback) + } + r.Group(func(r chi.Router) { r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo)) r.Get("/me", s.authH.Me) diff --git a/internal/service/auth_service.go b/internal/service/auth_service.go index e05eb5b..d0045ba 100644 --- a/internal/service/auth_service.go +++ b/internal/service/auth_service.go @@ -39,6 +39,7 @@ var ( type AuthService struct { users *repository.UserRepo tokens *repository.AuthTokenRepo + oauth *repository.OAuthAccountRepo // optional — nil disables OAuth flows jwt *auth.Issuer mail mailer.Mailer logger *zap.Logger @@ -49,10 +50,12 @@ type AuthService struct { EmailVerifyTTL time.Duration } -// NewAuthService wires the dependencies. +// NewAuthService wires the dependencies. `oauthRepo` may be nil — pass nil if +// the OAuth flows aren't wired (handler then returns 503). func NewAuthService( users *repository.UserRepo, tokens *repository.AuthTokenRepo, + oauthRepo *repository.OAuthAccountRepo, jwt *auth.Issuer, mail mailer.Mailer, logger *zap.Logger, @@ -61,6 +64,7 @@ func NewAuthService( return &AuthService{ users: users, tokens: tokens, + oauth: oauthRepo, jwt: jwt, mail: mail, logger: logger, @@ -136,12 +140,135 @@ func (s *AuthService) LogIn(ctx context.Context, email, password string) (*AuthR } return nil, err } - if err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)); err != nil { + // OAuth-only accounts have no password hash. Treat them as a credential + // mismatch so we don't reveal that an OAuth user exists for this email. + if u.PasswordHash == nil { + return nil, ErrInvalidCredentials + } + if err := bcrypt.CompareHashAndPassword([]byte(*u.PasswordHash), []byte(password)); err != nil { return nil, ErrInvalidCredentials } return s.issueSession(u) } +// OAuthProfile is the provider-agnostic profile shape consumed by +// SignInWithOAuth. Mirrors pkg/oauth.Profile but lives here so the service +// layer doesn't import the OAuth package (which would create a layering +// cycle once handlers compose both). +type OAuthProfile struct { + Provider string + ProviderUserID string + Email string + EmailVerified bool + Name string + AvatarURL string +} + +// ErrOAuthEmailMissing is returned by SignInWithOAuth when the provider did +// not give us an email and the account has no linkage we could match on. +// Without an email we can't safely create a new user (since later sign-ins +// via email/password would have no way to converge with this account). +var ErrOAuthEmailMissing = errors.New("oauth provider did not return an email") + +// ErrOAuthUnavailable means the OAuth repo wasn't wired (no Postgres at +// startup). Handler maps to 503. +var ErrOAuthUnavailable = errors.New("oauth service unavailable") + +// SignInWithOAuth turns a provider profile into a session. Three-step lookup: +// +// 1. Existing linkage on (provider, provider_user_id) → log that user in. +// 2. Existing user with same email → link this provider to them, log in. +// 3. Otherwise create a new user (no password, email_verified iff provider +// said so) and link. +// +// In every case the resulting user gets a fresh JWT just like LogIn. +func (s *AuthService) SignInWithOAuth(ctx context.Context, prof OAuthProfile) (*AuthResult, error) { + if s.oauth == nil { + return nil, ErrOAuthUnavailable + } + + // 1. Known linkage. + if a, err := s.oauth.FindByProvider(ctx, prof.Provider, prof.ProviderUserID); err == nil { + u, err := s.users.FindByID(ctx, a.UserID) + if err != nil { + return nil, err + } + // Refresh denormalised fields on the linkage row (display name / + // avatar may have changed at the provider). + _, _ = s.oauth.Link(ctx, u.ID, prof.Provider, prof.ProviderUserID, + optStr(prof.Email), optStr(prof.Name), optStr(prof.AvatarURL)) + return s.issueSession(u) + } else if !errors.Is(err, repository.ErrOAuthAccountNotFound) { + return nil, err + } + + // No linkage yet — we need at least an email to safely converge with an + // existing account or create a new one. + if prof.Email == "" { + return nil, ErrOAuthEmailMissing + } + + // 2. Existing user with the same email → link. + if u, err := s.users.FindByEmail(ctx, prof.Email); err == nil { + if _, err := s.oauth.Link(ctx, u.ID, prof.Provider, prof.ProviderUserID, + optStr(prof.Email), optStr(prof.Name), optStr(prof.AvatarURL)); err != nil { + return nil, err + } + return s.issueSession(u) + } else if !errors.Is(err, repository.ErrUserNotFound) { + return nil, err + } + + // 3. Brand new user. Reuse the handle-collision retry loop. + displayName := prof.Name + if displayName == "" { + displayName = strings.SplitN(prof.Email, "@", 2)[0] + } + base := slugForHandle(prof.Email) + var u *model.User + var err error + for attempt := 0; attempt < 8; attempt++ { + handle := base + if attempt > 0 { + handle = fmt.Sprintf("%s-%s", base, randomHandleSuffix()) + } + u, err = s.users.CreateOAuth(ctx, prof.Email, displayName, handle, optStr(prof.AvatarURL), prof.EmailVerified) + if err == nil { + break + } + if errors.Is(err, repository.ErrEmailTaken) { + // Race: someone else just claimed this email. Fall back to step 2. + if u2, err2 := s.users.FindByEmail(ctx, prof.Email); err2 == nil { + _, _ = s.oauth.Link(ctx, u2.ID, prof.Provider, prof.ProviderUserID, + optStr(prof.Email), optStr(prof.Name), optStr(prof.AvatarURL)) + return s.issueSession(u2) + } + return nil, err + } + if !errors.Is(err, repository.ErrHandleTaken) { + return nil, err + } + } + if err != nil { + return nil, fmt.Errorf("create oauth user: %w", err) + } + + if _, err := s.oauth.Link(ctx, u.ID, prof.Provider, prof.ProviderUserID, + optStr(prof.Email), optStr(prof.Name), optStr(prof.AvatarURL)); err != nil { + return nil, err + } + return s.issueSession(u) +} + +// optStr returns nil for empty input, &s otherwise — used to populate +// nullable text columns from possibly-empty profile fields. +func optStr(s string) *string { + if s == "" { + return nil + } + return &s +} + // Me looks up a user by id and re-verifies the JWT's token_version against // the stored value. Mismatch means the user has logged-out-everywhere or // changed password since this JWT was issued → reject. diff --git a/migrations/0012_create_oauth_accounts.down.sql b/migrations/0012_create_oauth_accounts.down.sql new file mode 100644 index 0000000..dab7c67 --- /dev/null +++ b/migrations/0012_create_oauth_accounts.down.sql @@ -0,0 +1,3 @@ +DROP TABLE IF EXISTS oauth_accounts; +-- Note: re-imposing NOT NULL on users.password_hash would require backfilling +-- any OAuth-only users with a dummy hash; left as a manual step. diff --git a/migrations/0012_create_oauth_accounts.up.sql b/migrations/0012_create_oauth_accounts.up.sql new file mode 100644 index 0000000..cdc70c0 --- /dev/null +++ b/migrations/0012_create_oauth_accounts.up.sql @@ -0,0 +1,35 @@ +-- OAuth account linkage. +-- +-- One row per (provider, provider_user_id). A single user can have several +-- providers linked (e.g. signed up with email + later linked Google). The +-- provider's `sub` (Google) / `id` (Facebook) is the stable identity — email +-- is captured for display but is NOT the join key, since providers can let +-- users change the email on the account. +-- +-- When a callback fires: +-- 1. Look up (provider, provider_user_id) — found → log that user in. +-- 2. Otherwise look up users.email — found → link new oauth_accounts row +-- to the existing user and log them in. +-- 3. Otherwise create a new user (password_hash NULL) + an oauth_accounts +-- row, log them in. +CREATE TABLE IF NOT EXISTS oauth_accounts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + provider VARCHAR(32) NOT NULL, -- "google" | "facebook" + provider_user_id TEXT NOT NULL, -- sub (Google) / id (Facebook) + email CITEXT, + name TEXT, + avatar_url TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (provider, provider_user_id) +); + +CREATE INDEX IF NOT EXISTS oauth_accounts_user_id_idx ON oauth_accounts (user_id); +CREATE INDEX IF NOT EXISTS oauth_accounts_provider_idx ON oauth_accounts (provider); + +-- Password hash must be optional now: OAuth-only users have no password. +-- Existing email/password users keep their hash; new OAuth users get NULL +-- and can set a password later via the forgot-password flow if they want +-- a fallback login method. +ALTER TABLE users ALTER COLUMN password_hash DROP NOT NULL; diff --git a/pkg/oauth/oauth.go b/pkg/oauth/oauth.go new file mode 100644 index 0000000..a6bd483 --- /dev/null +++ b/pkg/oauth/oauth.go @@ -0,0 +1,213 @@ +// Package oauth wraps the third-party sign-in providers used by the BFF. +// +// Design: each provider is a small struct that knows three things — +// - its golang.org/x/oauth2.Config (client id / secret / scopes / endpoint) +// - how to fetch a normalised Profile from an access token +// - its registry name ("google", "facebook") +// +// The handler layer is provider-agnostic: it picks a Registry entry by URL +// param, generates state, redirects to AuthCodeURL, then on callback +// exchanges the code and calls FetchProfile. The service layer then takes +// the Profile and turns it into a session (find-or-create user + JWT). +package oauth + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + + "golang.org/x/oauth2" + googleEndpoint "golang.org/x/oauth2/google" +) + +// Profile is the normalised shape we extract from every provider. +// +// Email may be empty when the user denied the email scope (rare on Google, +// possible on Facebook accounts with no verified email). Callers must treat +// an empty email as a hard error — we use email as a fallback link key. +type Profile struct { + Provider string // "google" | "facebook" + ProviderUserID string // sub (Google) / id (Facebook) + Email string // lowercased + EmailVerified bool // Google sets this; Facebook does not — default true + Name string // display name, may be empty + AvatarURL string // absolute URL, may be empty +} + +// Provider is the per-vendor adapter. Stateless after construction. +type Provider interface { + Name() string + Config(redirectURL string) *oauth2.Config + FetchProfile(ctx context.Context, tok *oauth2.Token) (*Profile, error) +} + +// Registry maps provider name → Provider. Built from config at server start; +// only providers with both client ID and secret set become available. +type Registry map[string]Provider + +// NewRegistry builds the live registry. Missing creds for a provider simply +// omit it — the start handler returns 404 for unconfigured providers. +func NewRegistry(cfg Credentials) Registry { + r := Registry{} + if cfg.GoogleClientID != "" && cfg.GoogleClientSecret != "" { + r["google"] = &google{id: cfg.GoogleClientID, secret: cfg.GoogleClientSecret} + } + if cfg.FacebookClientID != "" && cfg.FacebookClientSecret != "" { + r["facebook"] = &facebook{id: cfg.FacebookClientID, secret: cfg.FacebookClientSecret} + } + return r +} + +// Credentials is the subset of app config the registry needs. +type Credentials struct { + GoogleClientID string + GoogleClientSecret string + FacebookClientID string + FacebookClientSecret string +} + +// Has reports whether the registry knows the named provider. +func (r Registry) Has(name string) bool { _, ok := r[name]; return ok } + +// Get returns the provider, or ErrUnknownProvider. +func (r Registry) Get(name string) (Provider, error) { + p, ok := r[name] + if !ok { + return nil, ErrUnknownProvider + } + return p, nil +} + +// ErrUnknownProvider is returned when the URL param doesn't match any +// configured provider. +var ErrUnknownProvider = errors.New("unknown oauth provider") + +// --- Google --- + +type google struct{ id, secret string } + +func (g *google) Name() string { return "google" } + +func (g *google) Config(redirectURL string) *oauth2.Config { + return &oauth2.Config{ + ClientID: g.id, + ClientSecret: g.secret, + RedirectURL: redirectURL, + Scopes: []string{"openid", "email", "profile"}, + Endpoint: googleEndpoint.Endpoint, + } +} + +// googleUserinfo is the subset of the v3 userinfo response we read. The +// full payload has more fields (locale, given_name, family_name) we don't +// currently need. +type googleUserinfo struct { + Sub string `json:"sub"` + Email string `json:"email"` + EmailVerified bool `json:"email_verified"` + Name string `json:"name"` + Picture string `json:"picture"` +} + +func (g *google) FetchProfile(ctx context.Context, tok *oauth2.Token) (*Profile, error) { + const url = "https://openidconnect.googleapis.com/v1/userinfo" + body, err := httpGet(ctx, tok, url) + if err != nil { + return nil, fmt.Errorf("google userinfo: %w", err) + } + var u googleUserinfo + if err := json.Unmarshal(body, &u); err != nil { + return nil, fmt.Errorf("google userinfo decode: %w", err) + } + if u.Sub == "" { + return nil, fmt.Errorf("google userinfo: empty sub") + } + return &Profile{ + Provider: "google", + ProviderUserID: u.Sub, + Email: strings.ToLower(strings.TrimSpace(u.Email)), + EmailVerified: u.EmailVerified, + Name: strings.TrimSpace(u.Name), + AvatarURL: u.Picture, + }, nil +} + +// --- Facebook --- + +type facebook struct{ id, secret string } + +func (f *facebook) Name() string { return "facebook" } + +func (f *facebook) Config(redirectURL string) *oauth2.Config { + return &oauth2.Config{ + ClientID: f.id, + ClientSecret: f.secret, + RedirectURL: redirectURL, + Scopes: []string{"email", "public_profile"}, + Endpoint: oauth2.Endpoint{ + AuthURL: "https://www.facebook.com/v18.0/dialog/oauth", + TokenURL: "https://graph.facebook.com/v18.0/oauth/access_token", + }, + } +} + +// facebookMe captures the fields requested via ?fields=id,email,name,picture. +// picture is an envelope: { data: { url, ... } }. +type facebookMe struct { + ID string `json:"id"` + Email string `json:"email"` + Name string `json:"name"` + Picture struct { + Data struct { + URL string `json:"url"` + } `json:"data"` + } `json:"picture"` +} + +func (f *facebook) FetchProfile(ctx context.Context, tok *oauth2.Token) (*Profile, error) { + const url = "https://graph.facebook.com/v18.0/me?fields=id,email,name,picture.type(large)" + body, err := httpGet(ctx, tok, url) + if err != nil { + return nil, fmt.Errorf("facebook /me: %w", err) + } + var u facebookMe + if err := json.Unmarshal(body, &u); err != nil { + return nil, fmt.Errorf("facebook /me decode: %w", err) + } + if u.ID == "" { + return nil, fmt.Errorf("facebook /me: empty id") + } + return &Profile{ + Provider: "facebook", + ProviderUserID: u.ID, + Email: strings.ToLower(strings.TrimSpace(u.Email)), + EmailVerified: u.Email != "", // Facebook returns an email only if user granted the scope; we trust it + Name: strings.TrimSpace(u.Name), + AvatarURL: u.Picture.Data.URL, + }, nil +} + +// httpGet performs a token-authenticated GET and returns the response body. +// The provider tokens include their auth header already (Bearer for Google, +// query-param for Facebook), so we let oauth2.Client handle it. +func httpGet(ctx context.Context, tok *oauth2.Token, url string) ([]byte, error) { + cli := oauth2.NewClient(ctx, oauth2.StaticTokenSource(tok)) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + resp, err := cli.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("status %d: %s", resp.StatusCode, string(body)) + } + return io.ReadAll(resp.Body) +} diff --git a/pkg/oauth/state.go b/pkg/oauth/state.go new file mode 100644 index 0000000..2b70aaf --- /dev/null +++ b/pkg/oauth/state.go @@ -0,0 +1,91 @@ +package oauth + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "strings" + "time" +) + +// statePayload is the JSON shape we round-trip through the OAuth state +// parameter. The cookie holds the same encoded blob; on callback we compare +// the two byte-for-byte before trusting either. +// +// `Next` is the post-login redirect path on the frontend. We sign it (along +// with Nonce + IssuedAt) under HMAC so a tampered ?next= can't redirect the +// user to an arbitrary URL after sign-in. +type statePayload struct { + Nonce string `json:"n"` // 16 random bytes, base64-urlencoded + Next string `json:"u"` // safe path, validated by handler + IssuedAt int64 `json:"t"` // unix seconds + Provider string `json:"p"` // "google" | "facebook" +} + +// stateMaxAge bounds how long a /start may be in flight. Ten minutes is +// generous — typical OAuth round-trips finish in seconds. Longer than this +// and we discard the state to keep the replay window small. +const stateMaxAge = 10 * time.Minute + +// EncodeState signs and packs a fresh state value bound to the given provider +// and post-login redirect path. The returned string is what goes into both +// the `state` query param and the cookie. +func EncodeState(secret []byte, provider, next string) (string, error) { + nonceB := make([]byte, 16) + if _, err := rand.Read(nonceB); err != nil { + return "", fmt.Errorf("state nonce: %w", err) + } + p := statePayload{ + Nonce: base64.RawURLEncoding.EncodeToString(nonceB), + Next: next, + IssuedAt: time.Now().Unix(), + Provider: provider, + } + body, err := json.Marshal(p) + if err != nil { + return "", err + } + bodyEnc := base64.RawURLEncoding.EncodeToString(body) + mac := hmac.New(sha256.New, secret) + mac.Write([]byte(bodyEnc)) + sig := base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) + return bodyEnc + "." + sig, nil +} + +// DecodeState verifies the signature, checks the freshness window, and +// returns the embedded payload. Any failure surfaces as ErrInvalidState so +// the handler maps it to a single error path. +func DecodeState(secret []byte, raw string) (provider, next string, err error) { + parts := strings.SplitN(raw, ".", 2) + if len(parts) != 2 { + return "", "", ErrInvalidState + } + bodyEnc, sigEnc := parts[0], parts[1] + mac := hmac.New(sha256.New, secret) + mac.Write([]byte(bodyEnc)) + expected := base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) + if !hmac.Equal([]byte(expected), []byte(sigEnc)) { + return "", "", ErrInvalidState + } + body, err := base64.RawURLEncoding.DecodeString(bodyEnc) + if err != nil { + return "", "", ErrInvalidState + } + var p statePayload + if err := json.Unmarshal(body, &p); err != nil { + return "", "", ErrInvalidState + } + if time.Since(time.Unix(p.IssuedAt, 0)) > stateMaxAge { + return "", "", ErrInvalidState + } + return p.Provider, p.Next, nil +} + +// ErrInvalidState is returned whenever signature or freshness checks fail. +// We deliberately return one error so handlers don't leak whether a request +// was tampered or just stale. +var ErrInvalidState = errors.New("invalid or expired oauth state")