package auth import ( "context" "crypto/rand" "crypto/sha256" "database/sql" "encoding/base64" "encoding/json" "errors" "fmt" "io" "log" "net/http" "net/url" "regexp" "strings" "time" ) var projectName = regexp.MustCompile(`^[a-z][a-z0-9-]*$`) var tokenPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{43}$`) type service struct { db *sql.DB projects map[string]*url.URL redirectURI, clientID, secret string google *http.Client } func New(db *sql.DB, callbacks map[string]string, redirectURI, id, secret string) (http.Handler, error) { s := &service{db: db, projects: make(map[string]*url.URL), redirectURI: redirectURI, clientID: id, secret: secret, google: &http.Client{Timeout: 10 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}} for name, raw := range callbacks { u, err := url.Parse(raw) if err != nil { return nil, fmt.Errorf("invalid project callback for %q", name) } host := u.Hostname() local := u.Scheme == "http" && (host == "localhost" || host == "127.0.0.1" || host == "::1") if !projectName.MatchString(name) || host == "" || (u.Scheme != "https" && !local) || u.User != nil || u.Fragment != "" { return nil, fmt.Errorf("invalid project callback for %q", name) } s.projects[name] = u } mux := http.NewServeMux() mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { reply(w, 200, payload{"status": "ok"}) }) mux.HandleFunc("GET /readyz", s.wrap(func(w http.ResponseWriter, r *http.Request) error { if _, err := s.db.ExecContext(r.Context(), "SELECT 1 FROM project_accounts LIMIT 1"); err != nil { return err } reply(w, 200, payload{"status": "ready"}) return nil })) mux.HandleFunc("GET /auth/google/callback", s.wrap(s.callback)) mux.HandleFunc("/projects/{project}/auth/{action}", s.wrap(s.project)) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "no-store") w.Header().Set("Referrer-Policy", "no-referrer") ctx, cancel := context.WithTimeout(r.Context(), 25*time.Second) defer cancel() mux.ServeHTTP(w, r.WithContext(ctx)) }), nil } type payload map[string]any func reply(w http.ResponseWriter, status int, body payload) { if body != nil { w.Header().Set("Content-Type", "application/json") } w.WriteHeader(status) if body != nil { _ = json.NewEncoder(w).Encode(body) } } func reject(w http.ResponseWriter, status int, code string) error { reply(w, status, payload{"error": code}) return nil } func (s *service) wrap(handle func(http.ResponseWriter, *http.Request) error) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if err := handle(w, r); err != nil { log.Printf("request failed (%T)", err) _ = reject(w, 503, "service_unavailable") } } } func token() string { var value [32]byte rand.Read(value[:]) return base64.RawURLEncoding.EncodeToString(value[:]) } func digest(value string) string { sum := sha256.Sum256([]byte(value)) return base64.RawURLEncoding.EncodeToString(sum[:]) } func (s *service) project(w http.ResponseWriter, r *http.Request) error { project, action := r.PathValue("project"), r.PathValue("action") frontend := s.projects[project] if frontend == nil { return reject(w, 404, "not_found") } if r.Method == http.MethodGet && action == "google" { return s.start(w, r, project) } w.Header().Set("Vary", "Origin") if origin := r.Header.Get("Origin"); origin != "" { if origin != frontend.Scheme+"://"+frontend.Host { return reject(w, 403, "origin_denied") } w.Header().Set("Access-Control-Allow-Origin", origin) } switch { case r.Method == http.MethodOptions: w.Header().Set("Access-Control-Allow-Methods", "GET, POST") w.Header().Set("Access-Control-Allow-Headers", "Authorization, X-Foundry-Code, X-Foundry-Verifier") reply(w, 204, nil) case r.Method == http.MethodPost && action == "exchange": return s.exchange(w, r, project) case r.Method == http.MethodGet && action == "me": return s.account(w, r, project, false) case r.Method == http.MethodPost && action == "logout": return s.account(w, r, project, true) default: return reject(w, 405, "method_not_allowed") } return nil } func oauthCookie(state, value string, maxAge int) *http.Cookie { return &http.Cookie{Name: "__Host-foundry-oauth-" + state, Value: value, Path: "/", MaxAge: maxAge, Secure: true, HttpOnly: true, SameSite: http.SameSiteLaxMode} } func (s *service) start(w http.ResponseWriter, r *http.Request, project string) error { challenge := r.URL.Query().Get("challenge") if !tokenPattern.MatchString(challenge) { return reject(w, 400, "invalid_challenge") } state, browser, verifier := token(), token(), token() _, err := s.db.ExecContext(r.Context(), ` INSERT INTO oauth_attempts VALUES ($1, $2, $3, $4, $5, now() + interval '10 minutes') `, digest(state), digest(browser), project, challenge, verifier) if err != nil { return err } query := url.Values{ "client_id": {s.clientID}, "redirect_uri": {s.redirectURI}, "response_type": {"code"}, "scope": {"openid email"}, "state": {state}, "code_challenge": {digest(verifier)}, "code_challenge_method": {"S256"}, "prompt": {"select_account"}, } http.SetCookie(w, oauthCookie(state, browser, 600)) http.Redirect(w, r, "https://accounts.google.com/o/oauth2/v2/auth?"+query.Encode(), 302) return nil } func (s *service) googleJSON(ctx context.Context, method, endpoint string, form url.Values, bearer string, result any) error { var body io.Reader if form != nil { body = strings.NewReader(form.Encode()) } req, err := http.NewRequestWithContext(ctx, method, endpoint, body) if err != nil { return err } if form != nil { req.Header.Set("Content-Type", "application/x-www-form-urlencoded") } if bearer != "" { req.Header.Set("Authorization", "Bearer "+bearer) } res, err := s.google.Do(req) if err != nil { return err } defer res.Body.Close() if res.StatusCode != 200 { return fmt.Errorf("Google returned HTTP %d", res.StatusCode) } return json.NewDecoder(io.LimitReader(res.Body, 1<<20)).Decode(result) } func (s *service) callback(w http.ResponseWriter, r *http.Request) error { ctx := r.Context() query := r.URL.Query() state := query.Get("state") if !tokenPattern.MatchString(state) { return reject(w, 400, "invalid_state") } browser, err := r.Cookie(oauthCookie(state, "", 0).Name) if err != nil || !tokenPattern.MatchString(browser.Value) { return reject(w, 400, "invalid_state") } var project, challenge, verifier string err = s.db.QueryRowContext(ctx, ` DELETE FROM oauth_attempts WHERE state_hash = $1 AND browser_hash = $2 AND expires_at > now() RETURNING project, frontend_challenge, google_verifier `, digest(state), digest(browser.Value)).Scan(&project, &challenge, &verifier) if errors.Is(err, sql.ErrNoRows) { return reject(w, 400, "invalid_state") } if err != nil { return err } frontend := s.projects[project] if frontend == nil { return reject(w, 400, "invalid_state") } http.SetCookie(w, oauthCookie(state, "", -1)) destination := *frontend code := query.Get("code") if code == "" || query.Has("error") { destination.Fragment = "error=google_login_cancelled" http.Redirect(w, r, destination.String(), 302) return nil } var tokens struct { AccessToken string `json:"access_token"` } err = s.googleJSON(ctx, "POST", "https://oauth2.googleapis.com/token", url.Values{ "code": {code}, "client_id": {s.clientID}, "client_secret": {s.secret}, "redirect_uri": {s.redirectURI}, "grant_type": {"authorization_code"}, "code_verifier": {verifier}, }, "", &tokens) if err != nil { return err } if tokens.AccessToken == "" { return errors.New("missing Google access token") } var user struct { Subject string `json:"sub"` Email *string `json:"email"` Verified bool `json:"email_verified"` } if err := s.googleJSON(ctx, "GET", "https://openidconnect.googleapis.com/v1/userinfo", nil, tokens.AccessToken, &user); err != nil { return err } if user.Subject == "" { return errors.New("missing Google subject") } if !user.Verified { user.Email = nil } grant := token() _, err = s.db.ExecContext(ctx, ` WITH account AS ( INSERT INTO project_accounts (project, id, google_subject, email) VALUES ($1, gen_random_uuid(), $2, $3) ON CONFLICT (project, google_subject) DO UPDATE SET email = EXCLUDED.email RETURNING id ) INSERT INTO login_grants SELECT $4, $1, id, $5, now() + interval '1 minute' FROM account `, project, user.Subject, user.Email, digest(grant), challenge) if err != nil { return err } destination.Fragment = "code=" + grant http.Redirect(w, r, destination.String(), 302) return nil } func (s *service) exchange(w http.ResponseWriter, r *http.Request, project string) error { code, verifier := r.Header.Get("X-Foundry-Code"), r.Header.Get("X-Foundry-Verifier") if !tokenPattern.MatchString(code) || !tokenPattern.MatchString(verifier) { return reject(w, 400, "invalid_exchange") } session := token() result, err := s.db.ExecContext(r.Context(), ` WITH grant AS ( DELETE FROM login_grants WHERE token_hash = $1 AND project = $2 AND frontend_challenge = $3 AND expires_at > now() RETURNING account_id ) INSERT INTO project_sessions SELECT $4, $2, account_id, now() + interval '8 hours' FROM grant `, digest(code), project, digest(verifier), digest(session)) if err != nil { return err } count, err := result.RowsAffected() if err != nil { return err } if count == 0 { return reject(w, 401, "invalid_grant") } reply(w, 200, payload{"access_token": session, "token_type": "Bearer", "expires_in": 28800}) return nil } func (s *service) account(w http.ResponseWriter, r *http.Request, project string, logout bool) error { token, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ") if !ok || !tokenPattern.MatchString(token) { return reject(w, 401, "invalid_session") } if logout { _, err := s.db.ExecContext(r.Context(), "DELETE FROM project_sessions WHERE token_hash = $1 AND project = $2", digest(token), project) if err != nil { return err } reply(w, 204, nil) return nil } var id string var email *string err := s.db.QueryRowContext(r.Context(), ` SELECT a.id::text, a.email FROM project_sessions s JOIN project_accounts a ON a.project = s.project AND a.id = s.account_id WHERE s.token_hash = $1 AND s.project = $2 AND s.expires_at > now() `, digest(token), project).Scan(&id, &email) if errors.Is(err, sql.ErrNoRows) { return reject(w, 401, "invalid_session") } if err != nil { return err } reply(w, 200, payload{"project": project, "id": id, "email": email}) return nil }