From 3d31a5a03ed8b090f0b202efd8cb78d9bee4060f Mon Sep 17 00:00:00 2001 From: Dinesh Pandiyan Date: Mon, 26 Oct 2026 23:51:48 +1100 Subject: [PATCH] feat: Google sign-in and invite-only signup Google OIDC adapter (PKCE, state, nonce, browser-bound flow cookie). Accounts link by Google subject, so email changes keep the account. Admins create, list and revoke 48h single-use invites; signup needs a verified Google email matching the invite. Without Google config the server runs admin-only. --- cmd/diffx-server/main.go | 5 +- cmd/diffx-server/main_test.go | 17 ++ compose.yaml | 4 + docs-website/src/pages/agent-hooks.md | 5 +- docs/architecture.md | 6 +- docs/development.md | 7 +- docs/system.md | 13 + internal/config/server.go | 27 +- internal/config/server_test.go | 37 +++ internal/google/google.go | 147 ++++++++++ internal/google/google_test.go | 85 ++++++ internal/identityservice/google.go | 284 +++++++++++++++++++ internal/identityservice/identity.go | 10 +- internal/identityservice/identity_test.go | 42 +++ internal/remoteserver/admin.go | 86 ++++++ internal/remoteserver/admin_test.go | 2 +- internal/remoteserver/google.go | 121 ++++++++ internal/remoteserver/google_test.go | 308 +++++++++++++++++++++ internal/remoteserver/identity.go | 22 +- internal/remoteserver/server.go | 25 +- internal/reviewstore/invites.go | 179 ++++++++++++ internal/reviewstore/invites_test.go | 143 ++++++++++ packages/api/openapi.yaml | 235 ++++++++++++++++ packages/api/src/schema.d.ts | 321 ++++++++++++++++++++++ test/conformance/server.test.ts | 14 + tools/check-boundaries/main.go | 10 +- 26 files changed, 2136 insertions(+), 19 deletions(-) create mode 100644 internal/google/google.go create mode 100644 internal/google/google_test.go create mode 100644 internal/identityservice/google.go create mode 100644 internal/remoteserver/google.go create mode 100644 internal/remoteserver/google_test.go create mode 100644 internal/reviewstore/invites.go create mode 100644 internal/reviewstore/invites_test.go diff --git a/cmd/diffx-server/main.go b/cmd/diffx-server/main.go index a61e6515..6de32b1c 100644 --- a/cmd/diffx-server/main.go +++ b/cmd/diffx-server/main.go @@ -71,9 +71,12 @@ func parseSettings(arguments []string, stderr io.Writer) (remoteserver.Settings, return settings, false, fmt.Errorf("state must be a database path or memory") } values.PublicURL, values.InsecureCookies = settings.PublicURL, settings.InsecureCookies - if err := values.ValidatePublicURL(); err != nil { + if err := values.ValidateIdentity(); err != nil { return settings, false, err } + if values.Google.Enabled() { + settings.Google = &remoteserver.GoogleSettings{ClientID: values.Google.ClientID, ClientSecret: values.Google.ClientSecret} + } if _, err := remoteserver.Retention(settings.RetentionDays); err != nil { return settings, false, err } diff --git a/cmd/diffx-server/main_test.go b/cmd/diffx-server/main_test.go index d2bf33cd..58b89260 100644 --- a/cmd/diffx-server/main_test.go +++ b/cmd/diffx-server/main_test.go @@ -144,6 +144,23 @@ func TestRemoteIdentityConfigPrecedence(t *testing.T) { } } +func TestRemoteGoogleSignInSettings(t *testing.T) { + t.Setenv("DIFFX_SERVER_CONFIG", "") + settings, _, err := parseSettings(nil, io.Discard) + if err != nil || settings.Google != nil { + t.Fatalf("Google sign-in without configuration: %+v %v", settings.Google, err) + } + t.Setenv("DIFFX_SERVER_GOOGLE_CLIENT_ID", "client") + t.Setenv("DIFFX_SERVER_GOOGLE_CLIENT_SECRET", "secret") + if _, _, err := parseSettings(nil, io.Discard); err == nil { + t.Fatal("Google sign-in accepted without a public URL") + } + settings, _, err = parseSettings([]string{"--public-url", "https://diffx.example.com"}, io.Discard) + if err != nil || settings.Google == nil || settings.Google.ClientID != "client" || settings.Google.ClientSecret != "secret" { + t.Fatalf("Google sign-in settings: %+v %v", settings.Google, err) + } +} + func TestRemoteRejectsInvalidDeploymentSettings(t *testing.T) { for _, tc := range []struct{ key, value string }{ {"DIFFX_SERVER_HOST", "localhost"}, diff --git a/compose.yaml b/compose.yaml index b3888107..d9392d99 100644 --- a/compose.yaml +++ b/compose.yaml @@ -13,6 +13,10 @@ services: # Until the admin exists, each start logs a one-time setup link on this # origin; no default credentials are generated. DIFFX_SERVER_PUBLIC_URL: "${DIFFX_SERVER_PUBLIC_URL:-}" + # Google sign-in for invited accounts: set both (empty runs admin-only). + # Needs DIFFX_SERVER_PUBLIC_URL for the callback. + DIFFX_SERVER_GOOGLE_CLIENT_ID: "${DIFFX_SERVER_GOOGLE_CLIENT_ID:-}" + DIFFX_SERVER_GOOGLE_CLIENT_SECRET: "${DIFFX_SERVER_GOOGLE_CLIENT_SECRET:-}" # Loopback HTTP development only. Use false behind production HTTPS. DIFFX_SERVER_INSECURE_COOKIES: "${DIFFX_SERVER_INSECURE_COOKIES:-true}" # GitHub App integration: set all or none (empty disables it), then start diff --git a/docs-website/src/pages/agent-hooks.md b/docs-website/src/pages/agent-hooks.md index a94cc0d6..a967098f 100644 --- a/docs-website/src/pages/agent-hooks.md +++ b/docs-website/src/pages/agent-hooks.md @@ -100,7 +100,10 @@ DIFFX_SERVER_INSECURE_COOKIES=true DIFFX_SERVER_STATE=./test-data/state.db diffx Open the setup link the server logs to create the admin account. The admin signs in with that password at `/admin/login`; everyone else signs in with Google from -an admin invite. Then open the top-right account settings to create +an admin invite. Invites need a Google OAuth web client: set +`DIFFX_SERVER_GOOGLE_CLIENT_ID`, `DIFFX_SERVER_GOOGLE_CLIENT_SECRET` and +`DIFFX_SERVER_PUBLIC_URL`, and register +`/api/v2/auth/google/callback` as its redirect URI. Then open the top-right account settings to create a named API token. `collector` tokens support sync and plugins; `api` tokens also support the user's review REST/MCP APIs. Both expire after 90 days by default (selectable 1–365 days), can be revoked, and show their secret only once. Browser diff --git a/docs/architecture.md b/docs/architecture.md index 1dad6f90..f3fc1b6d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -71,8 +71,10 @@ personal lifecycle discovery. - `reviewservice`: shared snapshot reads, comments, marks and capability checks. - `reviewdata`: storage-independent identities, results and errors. - `reviewsession`: a resolved review and its source/policy capabilities. -- `identityservice`: account, admin setup, password, session, API-token and - account administration policies. +- `identityservice`: account, admin setup, password, session, API-token, + account administration, invite and Google sign-in policies. +- `google`: Google OpenID Connect wire adapter: authorization URL with PKCE and + nonce, code exchange and ID token claim checks. No storage or diffx policy. - `github`: GitHub wire adapter: app JWT, installation tokens, OAuth web flow, REST reads, webhook signatures and payloads. No storage or diffx policy. - `githubsync`: GitHub integration rules: which accounts may follow which diff --git a/docs/development.md b/docs/development.md index 1c312e89..64202543 100644 --- a/docs/development.md +++ b/docs/development.md @@ -187,6 +187,7 @@ building the image. | `DIFFX_SERVER_RETENTION_DAYS` | `7` | `7` | `--retention-days DAYS` | | `DIFFX_SERVER_PUBLIC_URL` | Unset | Unset | `--public-url URL` | | `DIFFX_SERVER_INSECURE_COOKIES` | `false` | Compose: `true` | `--insecure-cookies` | +| `DIFFX_SERVER_GOOGLE_CLIENT_ID` | Unset | Unset | None | The server listens on 7980 so a local `diffx [PATH]` (7981–7990) and the server run side by side. The `DIFFX_SERVER_` prefix keeps deployment variables from @@ -202,7 +203,11 @@ unset, the link uses the listen address. `memory` and `:memory:` disable persistence. The admin signs in at `/admin/login`; every other account signs in with Google -and comes from an admin invite. Create a named token in Settings. Use the +and comes from an admin invite. Google sign-in needs +`DIFFX_SERVER_GOOGLE_CLIENT_ID`, `DIFFX_SERVER_GOOGLE_CLIENT_SECRET` (environment +only, never the config file) and `DIFFX_SERVER_PUBLIC_URL`; register +`/api/v2/auth/google/callback` as the OAuth client's redirect URI. +Without them the server runs admin-only and cannot create invites. Create a named token in Settings. Use the `collector` preset for producers, or `api` for review APIs/MCP. Token secrets are shown once; expiry defaults to 90 days (selectable 1–365). Tokens cannot manage accounts. Browser sessions are seven-day opaque cookies with CSRF protection on diff --git a/docs/system.md b/docs/system.md index acd1d161..172c7449 100644 --- a/docs/system.md +++ b/docs/system.md @@ -364,6 +364,19 @@ concurrent attempts yield one admin. Only the admin signs in with a password, at login is indistinguishable from a wrong password. Offline `user reset-password` recovers only the admin. +Accounts other than the admin exist only through invites. The admin invites an +email; the link carries a hashed one-time secret in its fragment, is shown once, +expires after 48 hours and replaces that email's pending invite. Accepting +requires Google sign-in reporting a verified email equal to the invite's, and +uses the invite and creates the account in one transaction. The username derives +from the email. Google accounts are identified by their stable subject, not +email: a changed email keeps the account and updates its recorded email. Google +sign-in uses the authorization code flow with PKCE, state and nonce; a short-lived +HttpOnly cookie binds the flow to the starting browser and each state is consumed +once. A Google account without an account or invite cannot sign up. Without Google +configuration the server runs admin-only: Google routes are absent and invites +cannot be created. + The admin lists accounts and disables, enables or deletes any account except itself. Disabling revokes the account's browser sessions and API tokens in the same transaction and refuses new ones; enabling restores sign-in, not those diff --git a/internal/config/server.go b/internal/config/server.go index c1b1a968..76503262 100644 --- a/internal/config/server.go +++ b/internal/config/server.go @@ -32,8 +32,19 @@ type ServerValues struct { // InternalPort listens beside Port, on the same host, when GitHub is enabled. InternalPort int `json:"internalPort"` GitHub GitHub `json:"github"` + Google Google `json:"google"` } +// Google configures Google sign-in for invited accounts. The secret comes only +// from DIFFX_SERVER_GOOGLE_CLIENT_SECRET, never the config file. +type Google struct { + ClientID string `json:"clientId"` + ClientSecret string `json:"-"` +} + +// Enabled reports whether any Google setting is present; Validate requires all. +func (google Google) Enabled() bool { return google != Google{} } + // GitHub configures the optional GitHub App integration. Secrets come only from // DIFFX_SERVER_GITHUB_* environment variables, never the config file. type GitHub struct { @@ -100,11 +111,21 @@ func validOrigin(raw string, insecureCookies bool) bool { (public.Scheme == "https" || public.Scheme == "http" && insecureCookies) } -// ValidatePublicURL checks the optional origin used in links the server issues. -func (values ServerValues) ValidatePublicURL() error { +// ValidateIdentity checks the optional origin used in links the server issues, +// and Google sign-in, which needs both credentials and that origin. +func (values ServerValues) ValidateIdentity() error { if values.PublicURL != "" && !validOrigin(values.PublicURL, values.InsecureCookies) { return errors.New("publicUrl must be an https origin such as https://diffx.example.com (http only with insecure cookies)") } + if !values.Google.Enabled() { + return nil + } + if values.Google.ClientID == "" || values.Google.ClientSecret == "" { + return errors.New("incomplete Google sign-in; set both DIFFX_SERVER_GOOGLE_CLIENT_ID and DIFFX_SERVER_GOOGLE_CLIENT_SECRET") + } + if values.PublicURL == "" { + return errors.New("Google sign-in needs DIFFX_SERVER_PUBLIC_URL for its callback") + } return nil } @@ -141,6 +162,8 @@ func LoadServerFile(path string) (ServerValues, error) { {"DIFFX_SERVER_HOST", &values.Host}, {"DIFFX_SERVER_STATE", &values.State}, {"DIFFX_SERVER_PUBLIC_URL", &values.PublicURL}, + {"DIFFX_SERVER_GOOGLE_CLIENT_ID", &values.Google.ClientID}, + {"DIFFX_SERVER_GOOGLE_CLIENT_SECRET", &values.Google.ClientSecret}, {"DIFFX_SERVER_GITHUB_CLIENT_ID", &values.GitHub.ClientID}, {"DIFFX_SERVER_GITHUB_PRIVATE_KEY_FILE", &values.GitHub.PrivateKeyFile}, {"DIFFX_SERVER_GITHUB_PUBLIC_URL", &values.GitHub.PublicURL}, diff --git a/internal/config/server_test.go b/internal/config/server_test.go index d5951153..5dbd90d0 100644 --- a/internal/config/server_test.go +++ b/internal/config/server_test.go @@ -140,6 +140,43 @@ func TestGitHubConfigIsAllOrNothingWithSecretsFromEnvironment(t *testing.T) { } } +func TestGoogleSignInNeedsBothCredentialsAndPublicURL(t *testing.T) { + t.Setenv("DIFFX_SERVER_CONFIG", "") + t.Setenv("DIFFX_SERVER_GOOGLE_CLIENT_ID", "client") + t.Setenv("DIFFX_SERVER_GOOGLE_CLIENT_SECRET", "secret") + t.Setenv("DIFFX_SERVER_PUBLIC_URL", "https://diffx.example.com") + values, err := LoadServerFile("") + if err != nil || !values.Google.Enabled() || values.ValidateIdentity() != nil { + t.Fatalf("complete Google sign-in: %+v %v", values.Google, err) + } + for _, tc := range []struct{ name, want string }{ + {"DIFFX_SERVER_GOOGLE_CLIENT_SECRET", "DIFFX_SERVER_GOOGLE_CLIENT_SECRET"}, + {"DIFFX_SERVER_GOOGLE_CLIENT_ID", "DIFFX_SERVER_GOOGLE_CLIENT_ID"}, + {"DIFFX_SERVER_PUBLIC_URL", "DIFFX_SERVER_PUBLIC_URL"}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Setenv(tc.name, "") + values, err := LoadServerFile("") + if err != nil { + t.Fatal(err) + } + if err := values.ValidateIdentity(); err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("validation %v, want %q", err, tc.want) + } + }) + } + path := filepath.Join(t.TempDir(), "server.json") + if err := os.WriteFile(path, []byte(`{"google":{"clientId":"client","clientSecret":"in-file"}}`), 0o600); err != nil { + t.Fatal(err) + } + if _, err := LoadServerFile(path); err == nil || !strings.Contains(err.Error(), "clientSecret") { + t.Fatalf("secret accepted from config file: %v", err) + } + if (Google{}).Enabled() || (ServerValues{}).ValidateIdentity() != nil { + t.Fatal("absent Google sign-in must be valid and disabled") + } +} + func TestInternalListenerSharesHostOnSeparatePort(t *testing.T) { t.Setenv("DIFFX_SERVER_CONFIG", "") values, err := LoadServerFile("") diff --git a/internal/google/google.go b/internal/google/google.go new file mode 100644 index 00000000..ec011e95 --- /dev/null +++ b/internal/google/google.go @@ -0,0 +1,147 @@ +// Package google is the Google OpenID Connect wire adapter: authorization URLs +// and the authorization-code exchange. It holds no storage or diffx policy. +package google + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +const ( + authorizeURL = "https://accounts.google.com/o/oauth2/v2/auth" + tokenURL = "https://oauth2.googleapis.com/token" + // maxTokenResponse bounds the token endpoint body. + maxTokenResponse = 64 << 10 +) + +// ErrRejected reports an exchange or ID token Google or this client refused. +var ErrRejected = errors.New("google sign-in rejected") + +// Identity is the verified subject of an ID token. Subject is stable for the +// Google account; Email can change. +type Identity struct { + Subject string + Email string + EmailVerified bool +} + +type Client struct { + clientID, clientSecret, redirectURI string + authorizeURL, tokenURL string + http *http.Client + now func() time.Time +} + +type Option func(*Client) + +// WithEndpoints replaces Google's endpoints in tests. +func WithEndpoints(authorize, token string) Option { + return func(c *Client) { c.authorizeURL, c.tokenURL = authorize, token } +} + +func New(clientID, clientSecret, redirectURI string, options ...Option) *Client { + client := &Client{clientID: clientID, clientSecret: clientSecret, redirectURI: redirectURI, authorizeURL: authorizeURL, tokenURL: tokenURL, + http: &http.Client{Timeout: 15 * time.Second}, now: time.Now} + for _, option := range options { + option(client) + } + return client +} + +// Challenge derives the S256 PKCE challenge for a verifier. +func Challenge(verifier string) string { + digest := sha256.Sum256([]byte(verifier)) + return base64.RawURLEncoding.EncodeToString(digest[:]) +} + +func (c *Client) AuthorizeURL(state, nonce, verifier string) string { + query := url.Values{ + "client_id": {c.clientID}, "redirect_uri": {c.redirectURI}, "response_type": {"code"}, "scope": {"openid email"}, + "state": {state}, "nonce": {nonce}, "code_challenge": {Challenge(verifier)}, "code_challenge_method": {"S256"}, "prompt": {"select_account"}, + } + return c.authorizeURL + "?" + query.Encode() +} + +// Exchange redeems the code and validates the returned ID token's claims. The +// token comes directly from Google's token endpoint over TLS, so its signature +// is not checked (OpenID Connect Core 3.1.3.7). +func (c *Client) Exchange(ctx context.Context, code, verifier, nonce string) (Identity, error) { + if code == "" { + return Identity{}, fmt.Errorf("%w: missing code", ErrRejected) + } + form := url.Values{"code": {code}, "client_id": {c.clientID}, "client_secret": {c.clientSecret}, "redirect_uri": {c.redirectURI}, + "grant_type": {"authorization_code"}, "code_verifier": {verifier}} + request, err := http.NewRequestWithContext(ctx, http.MethodPost, c.tokenURL, strings.NewReader(form.Encode())) + if err != nil { + return Identity{}, err + } + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + request.Header.Set("Accept", "application/json") + response, err := c.http.Do(request) + if err != nil { + return Identity{}, err + } + defer response.Body.Close() + body, err := io.ReadAll(io.LimitReader(response.Body, maxTokenResponse)) + if err != nil { + return Identity{}, err + } + if response.StatusCode == http.StatusBadRequest || response.StatusCode == http.StatusUnauthorized { + return Identity{}, fmt.Errorf("%w: token endpoint returned %d", ErrRejected, response.StatusCode) + } + if response.StatusCode != http.StatusOK { + return Identity{}, fmt.Errorf("google token endpoint returned %d", response.StatusCode) + } + var token struct { + IDToken string `json:"id_token"` + } + if err := json.Unmarshal(body, &token); err != nil || token.IDToken == "" { + return Identity{}, fmt.Errorf("%w: token response missing id_token", ErrRejected) + } + return c.identity(token.IDToken, nonce) +} + +func (c *Client) identity(idToken, nonce string) (Identity, error) { + parts := strings.Split(idToken, ".") + if len(parts) != 3 { + return Identity{}, fmt.Errorf("%w: malformed id_token", ErrRejected) + } + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return Identity{}, fmt.Errorf("%w: malformed id_token", ErrRejected) + } + var claims struct { + Issuer string `json:"iss"` + Audience string `json:"aud"` + Expiry int64 `json:"exp"` + Nonce string `json:"nonce"` + Subject string `json:"sub"` + Email string `json:"email"` + EmailVerified bool `json:"email_verified"` + } + if err := json.Unmarshal(payload, &claims); err != nil { + return Identity{}, fmt.Errorf("%w: malformed id_token claims", ErrRejected) + } + switch { + case claims.Issuer != "https://accounts.google.com" && claims.Issuer != "accounts.google.com": + return Identity{}, fmt.Errorf("%w: unexpected issuer", ErrRejected) + case claims.Audience != c.clientID: + return Identity{}, fmt.Errorf("%w: unexpected audience", ErrRejected) + case claims.Expiry <= c.now().Unix(): + return Identity{}, fmt.Errorf("%w: expired id_token", ErrRejected) + case nonce == "" || claims.Nonce != nonce: + return Identity{}, fmt.Errorf("%w: nonce mismatch", ErrRejected) + case claims.Subject == "" || claims.Email == "": + return Identity{}, fmt.Errorf("%w: id_token missing subject or email", ErrRejected) + } + return Identity{Subject: claims.Subject, Email: claims.Email, EmailVerified: claims.EmailVerified}, nil +} diff --git a/internal/google/google_test.go b/internal/google/google_test.go new file mode 100644 index 00000000..9091064c --- /dev/null +++ b/internal/google/google_test.go @@ -0,0 +1,85 @@ +package google + +import ( + "encoding/base64" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" +) + +func idToken(t *testing.T, claims map[string]any) string { + t.Helper() + payload, err := json.Marshal(claims) + if err != nil { + t.Fatal(err) + } + return "e30." + base64.RawURLEncoding.EncodeToString(payload) + ".signature" +} + +func TestAuthorizeURLUsesPKCEAndNonce(t *testing.T) { + client := New("client", "secret", "https://diffx.example.com/api/v2/auth/google/callback") + parsed, err := url.Parse(client.AuthorizeURL("state", "nonce", "verifier")) + if err != nil { + t.Fatal(err) + } + query := parsed.Query() + if parsed.Host != "accounts.google.com" || query.Get("state") != "state" || query.Get("nonce") != "nonce" || query.Get("code_challenge") != Challenge("verifier") || + query.Get("code_challenge_method") != "S256" || query.Get("scope") != "openid email" || query.Get("redirect_uri") != "https://diffx.example.com/api/v2/auth/google/callback" { + t.Fatalf("authorize URL: %s", parsed) + } + if query.Get("code_challenge") == "verifier" { + t.Fatal("verifier leaked into the browser URL") + } +} + +func TestExchangeValidatesIDTokenClaims(t *testing.T) { + valid := map[string]any{"iss": "https://accounts.google.com", "aud": "client", "exp": time.Now().Add(time.Hour).Unix(), "nonce": "nonce", "sub": "subject", "email": "a@example.com", "email_verified": true} + var claims map[string]any + var form url.Values + status := http.StatusOK + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + t.Error(err) + } + form = r.PostForm + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(map[string]string{"id_token": idToken(t, claims)}) + })) + defer server.Close() + client := New("client", "secret", "https://diffx.example.com/callback", WithEndpoints(server.URL+"/auth", server.URL+"/token")) + claims = valid + identity, err := client.Exchange(t.Context(), "code", "verifier", "nonce") + if err != nil || identity != (Identity{Subject: "subject", Email: "a@example.com", EmailVerified: true}) { + t.Fatalf("exchange: %+v %v", identity, err) + } + if form.Get("code_verifier") != "verifier" || form.Get("client_secret") != "secret" || form.Get("grant_type") != "authorization_code" || form.Get("redirect_uri") != "https://diffx.example.com/callback" { + t.Fatalf("token request: %v", form) + } + for name, change := range map[string]func(map[string]any){ + "issuer": func(c map[string]any) { c["iss"] = "https://evil.example.com" }, + "audience": func(c map[string]any) { c["aud"] = "other" }, + "expiry": func(c map[string]any) { c["exp"] = time.Now().Add(-time.Minute).Unix() }, + "nonce": func(c map[string]any) { c["nonce"] = "replayed" }, + "subject": func(c map[string]any) { delete(c, "sub") }, + } { + claims = map[string]any{} + for key, value := range valid { + claims[key] = value + } + change(claims) + if _, err := client.Exchange(t.Context(), "code", "verifier", "nonce"); !errors.Is(err, ErrRejected) { + t.Fatalf("%s accepted: %v", name, err) + } + } + claims, status = valid, http.StatusBadRequest + if _, err := client.Exchange(t.Context(), "code", "verifier", "nonce"); !errors.Is(err, ErrRejected) { + t.Fatalf("rejected code accepted: %v", err) + } + if _, err := client.Exchange(t.Context(), "", "verifier", "nonce"); !errors.Is(err, ErrRejected) { + t.Fatalf("missing code accepted: %v", err) + } +} diff --git a/internal/identityservice/google.go b/internal/identityservice/google.go new file mode 100644 index 00000000..a7d37490 --- /dev/null +++ b/internal/identityservice/google.go @@ -0,0 +1,284 @@ +package identityservice + +import ( + "context" + "crypto/subtle" + "errors" + "fmt" + "strings" + "time" + + "github.com/flexdinesh/diffx/internal/google" +) + +const ( + InviteLifetime = 48 * time.Hour + GoogleFlowLifetime = 10 * time.Minute +) + +var ( + // ErrGoogleDisabled reports Google sign-in is not configured. + ErrGoogleDisabled = errors.New("google sign-in is not configured") + // ErrNoAccount reports a Google account without a diffx account or invite. + ErrNoAccount = errors.New("no account for this Google account") + // ErrInviteMismatch reports a verified Google email that differs from the invite. + ErrInviteMismatch = errors.New("google account does not match the invite") +) + +// GoogleClient is the Google wire adapter this service drives. +type GoogleClient interface { + AuthorizeURL(state, nonce, verifier string) string + Exchange(ctx context.Context, code, verifier, nonce string) (google.Identity, error) +} + +type Option func(*Service) + +// WithGoogle enables Google sign-in and invites. +func WithGoogle(client GoogleClient) Option { + return func(service *Service) { service.google = client } +} + +type InviteStatus string + +const ( + InvitePending InviteStatus = "pending" + InviteUsed InviteStatus = "used" + InviteExpired InviteStatus = "expired" + InviteRevoked InviteStatus = "revoked" +) + +type Invite struct { + ID string + Email string + CreatedAt time.Time + ExpiresAt time.Time + UsedAt *time.Time + // UsedBy is the username that accepted the invite while that account exists. + UsedBy string + RevokedAt *time.Time +} + +func (invite Invite) Status(now time.Time) InviteStatus { + switch { + case invite.UsedAt != nil: + return InviteUsed + case invite.RevokedAt != nil: + return InviteRevoked + case !now.Before(invite.ExpiresAt): + return InviteExpired + } + return InvitePending +} + +// GoogleFlow is one started authorization, bound to the starting browser. +type GoogleFlow struct { + BindingHash string + Nonce string + Verifier string + InviteID string + ExpiresAt time.Time +} + +// GoogleStore persists Google identities, invites and authorization flows. +type GoogleStore interface { + // CreateInvite revokes the email's pending invites in the same transaction; + // an email linked to an account is ErrConflict. + CreateInvite(ctx context.Context, invite Invite, tokenHash string) error + Invites(context.Context) ([]Invite, error) + Invite(ctx context.Context, id string) (Invite, error) + RevokeInvite(ctx context.Context, id string, at time.Time) error + // PendingInvite resolves an unexpired, unused, unrevoked invite by its secret. + PendingInvite(ctx context.Context, tokenHash string, now time.Time) (Invite, error) + // AcceptInvite creates the account and its Google identity and uses the + // pending invite in one transaction. A taken username is ErrConflict; an + // unavailable invite or an already linked subject is ErrNotFound. + AcceptInvite(ctx context.Context, inviteID string, account Account, identity google.Identity, now time.Time) error + // GoogleAccount resolves a linked subject, recording the latest email. + GoogleAccount(ctx context.Context, identity google.Identity, now time.Time) (account Account, disabled bool, err error) + PutGoogleFlow(ctx context.Context, stateHash string, flow GoogleFlow) error + // TakeGoogleFlow consumes an unexpired flow exactly once. + TakeGoogleFlow(ctx context.Context, stateHash string, now time.Time) (GoogleFlow, error) +} + +func (service *Service) GoogleEnabled() bool { return service.google != nil } + +func canonicalEmail(email string) (string, error) { + email = strings.ToLower(strings.TrimSpace(email)) + local, domain, found := strings.Cut(email, "@") + if !found || local == "" || !strings.Contains(domain, ".") || len(email) > 254 || strings.ContainsAny(email, " \t\r\n<>\"") || strings.Contains(domain, "@") { + return "", fmt.Errorf("%w: email address is invalid", ErrInvalidInput) + } + return email, nil +} + +// CreateInvite issues a single-use 48 hour invite for one email, replacing any +// pending invite for it. The secret is returned once and stored hashed. +func (service *Service) CreateInvite(ctx context.Context, email string) (Invite, string, error) { + if service.google == nil { + return Invite{}, "", ErrGoogleDisabled + } + email, err := canonicalEmail(email) + if err != nil { + return Invite{}, "", err + } + id, err := opaqueSecret() + if err != nil { + return Invite{}, "", err + } + secret, err := opaqueSecret() + if err != nil { + return Invite{}, "", err + } + now := service.now() + invite := Invite{ID: id, Email: email, CreatedAt: now, ExpiresAt: now.Add(InviteLifetime)} + if err := service.store.CreateInvite(ctx, invite, credentialHash(secret)); err != nil { + return Invite{}, "", err + } + return invite, secret, nil +} + +func (service *Service) Invites(ctx context.Context) ([]Invite, error) { + return service.store.Invites(ctx) +} + +func (service *Service) RevokeInvite(ctx context.Context, id string) error { + return service.store.RevokeInvite(ctx, id, service.now()) +} + +// PendingInvite resolves an invite link for the invite page. +func (service *Service) PendingInvite(ctx context.Context, secret string) (Invite, error) { + if service.google == nil { + return Invite{}, ErrGoogleDisabled + } + if len(secret) != 43 { + return Invite{}, ErrNotFound + } + return service.store.PendingInvite(ctx, credentialHash(secret), service.now()) +} + +// StartGoogle begins Google authorization, optionally for an invite. It returns +// the authorization URL and a browser binding the callback must present. +func (service *Service) StartGoogle(ctx context.Context, inviteSecret string) (string, string, error) { + if service.google == nil { + return "", "", ErrGoogleDisabled + } + var inviteID string + if inviteSecret != "" { + invite, err := service.PendingInvite(ctx, inviteSecret) + if err != nil { + return "", "", err + } + inviteID = invite.ID + } + secrets := make([]string, 4) + for index := range secrets { + secret, err := opaqueSecret() + if err != nil { + return "", "", err + } + secrets[index] = secret + } + state, binding, nonce, verifier := secrets[0], secrets[1], secrets[2], secrets[3] + flow := GoogleFlow{BindingHash: credentialHash(binding), Nonce: nonce, Verifier: verifier, InviteID: inviteID, ExpiresAt: service.now().Add(GoogleFlowLifetime)} + if err := service.store.PutGoogleFlow(ctx, credentialHash(state), flow); err != nil { + return "", "", err + } + return service.google.AuthorizeURL(state, nonce, verifier), binding, nil +} + +// CompleteGoogle finishes authorization in the browser that started it. A +// linked Google subject signs in, keeping its account when the email changes. +// Otherwise an invite flow creates the account when Google verified the +// invited email. +func (service *Service) CompleteGoogle(ctx context.Context, state, binding, code string) (Session, error) { + if service.google == nil { + return Session{}, ErrGoogleDisabled + } + if len(state) != 43 { + return Session{}, ErrAuthentication + } + flow, err := service.store.TakeGoogleFlow(ctx, credentialHash(state), service.now()) + if errors.Is(err, ErrNotFound) || err == nil && subtle.ConstantTimeCompare([]byte(flow.BindingHash), []byte(credentialHash(binding))) != 1 { + return Session{}, ErrAuthentication + } + if err != nil { + return Session{}, err + } + identity, err := service.google.Exchange(ctx, code, flow.Verifier, flow.Nonce) + if errors.Is(err, google.ErrRejected) { + return Session{}, ErrAuthentication + } + if err != nil { + return Session{}, err + } + account, disabled, err := service.store.GoogleAccount(ctx, identity, service.now()) + if err == nil { + if disabled { + return Session{}, ErrAuthentication + } + return service.issueSession(ctx, account, "") + } + if !errors.Is(err, ErrNotFound) { + return Session{}, err + } + if flow.InviteID == "" { + return Session{}, ErrNoAccount + } + return service.acceptInvite(ctx, flow.InviteID, identity) +} + +func (service *Service) acceptInvite(ctx context.Context, inviteID string, identity google.Identity) (Session, error) { + invite, err := service.store.Invite(ctx, inviteID) + if err != nil { + return Session{}, err + } + if invite.Status(service.now()) != InvitePending { + return Session{}, ErrNotFound + } + email, err := canonicalEmail(identity.Email) + if err != nil || !identity.EmailVerified || email != invite.Email { + return Session{}, ErrInviteMismatch + } + identity.Email = email + id, err := opaqueSecret() + if err != nil { + return Session{}, err + } + base := usernameFromEmail(email) + for attempt := 1; attempt <= 100; attempt++ { + username := base + if attempt > 1 { + suffix := fmt.Sprintf("-%d", attempt) + username = strings.TrimRight(base[:min(len(base), 32-len(suffix))], "._-") + suffix + } + account := Account{ID: id, Username: username} + err := service.store.AcceptInvite(ctx, inviteID, account, identity, service.now()) + if errors.Is(err, ErrConflict) { + continue + } + if err != nil { + return Session{}, err + } + return service.issueSession(ctx, account, "") + } + return Session{}, ErrConflict +} + +// usernameFromEmail derives a valid username from the email's local part. +func usernameFromEmail(email string) string { + local, _, _ := strings.Cut(email, "@") + var name strings.Builder + for _, character := range strings.ToLower(local) { + if character >= 'a' && character <= 'z' || character >= '0' && character <= '9' || name.Len() > 0 && (character == '.' || character == '-' || character == '_') { + name.WriteRune(character) + } + } + username := strings.TrimRight(name.String()[:min(name.Len(), 32)], "._-") + if username == "" { + return "user" + } + if len(username) < 3 { + username += "-user" + } + return username +} diff --git a/internal/identityservice/identity.go b/internal/identityservice/identity.go index 177a3090..3e752079 100644 --- a/internal/identityservice/identity.go +++ b/internal/identityservice/identity.go @@ -108,18 +108,24 @@ type Store interface { DeleteIdentityToken(context.Context, string, string) error ResolveIdentityToken(context.Context, string, time.Time) (TokenIdentity, error) IdentityAccounts(context.Context) ([]Account, error) + GoogleStore } type Service struct { store Store + google GoogleClient now func() time.Time dummyHash string } -func New(store Store) *Service { +func New(store Store, options ...Option) *Service { // A well-formed fixed dummy verifier pays the same Argon2 cost on misses. const dummy = "$argon2id$v=19$m=19456,t=2,p=1$AAAAAAAAAAAAAAAAAAAAAA$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" - return &Service{store: store, now: time.Now, dummyHash: dummy} + service := &Service{store: store, now: time.Now, dummyHash: dummy} + for _, option := range options { + option(service) + } + return service } func canonicalUsername(username string) (string, error) { diff --git a/internal/identityservice/identity_test.go b/internal/identityservice/identity_test.go index 63e37a07..2c127f22 100644 --- a/internal/identityservice/identity_test.go +++ b/internal/identityservice/identity_test.go @@ -96,3 +96,45 @@ func TestTokenPolicy(t *testing.T) { t.Fatalf("max expiry rejected: %v", err) } } + +func TestUsernameFromEmailAlwaysValid(t *testing.T) { + for email, want := range map[string]string{ + "Alice.Smith@example.com": "alice.smith", + "a@example.com": "a-user", + "_.@example.com": "user", + "bob+diffx@example.com": "bobdiffx", + "x_y-@example.com": "x_y", + strings.Repeat("long", 12) + "@gmail.com": strings.Repeat("long", 8), + } { + got := usernameFromEmail(email) + if got != want { + t.Fatalf("%s: %q want %q", email, got, want) + } + if _, err := canonicalUsername(got); err != nil { + t.Fatalf("%s derived invalid username %q: %v", email, got, err) + } + } +} + +func TestEmailPolicyAndInviteStatus(t *testing.T) { + for _, input := range []string{"", "alice", "alice@", "@example.com", "alice@example", "a b@example.com", "a@b@example.com"} { + if _, err := canonicalEmail(input); !errors.Is(err, ErrInvalidInput) { + t.Fatalf("accepted %q", input) + } + } + if email, err := canonicalEmail(" Alice@Example.COM "); err != nil || email != "alice@example.com" { + t.Fatalf("canonical email: %q %v", email, err) + } + now := time.Unix(1700000000, 0) + used, revoked := now, now + for invite, want := range map[*Invite]InviteStatus{ + {ExpiresAt: now.Add(time.Second)}: InvitePending, + {ExpiresAt: now}: InviteExpired, + {ExpiresAt: now.Add(time.Hour), UsedAt: &used}: InviteUsed, + {ExpiresAt: now.Add(time.Hour), RevokedAt: &revoked}: InviteRevoked, + } { + if got := invite.Status(now); got != want { + t.Fatalf("status %+v: %s want %s", invite, got, want) + } + } +} diff --git a/internal/remoteserver/admin.go b/internal/remoteserver/admin.go index b2d421c3..d85668fc 100644 --- a/internal/remoteserver/admin.go +++ b/internal/remoteserver/admin.go @@ -1,8 +1,10 @@ package remoteserver import ( + "errors" "net/http" "strings" + "time" "github.com/flexdinesh/diffx/internal/identityservice" ) @@ -24,9 +26,93 @@ func projectAdminUser(account identityservice.AccountSummary) adminUserJSON { return result } +type inviteJSON struct { + ID string `json:"id"` + Email string `json:"email"` + Status identityservice.InviteStatus `json:"status"` + CreatedAt int64 `json:"createdAt"` + ExpiresAt int64 `json:"expiresAt"` + UsedBy *string `json:"usedBy"` +} + +func projectInvite(invite identityservice.Invite, now time.Time) inviteJSON { + result := inviteJSON{ID: invite.ID, Email: invite.Email, Status: invite.Status(now), CreatedAt: invite.CreatedAt.UnixMilli(), ExpiresAt: invite.ExpiresAt.UnixMilli()} + if invite.UsedBy != "" { + result.UsedBy = &invite.UsedBy + } + return result +} + +// invites lists, creates and revokes invites. The link carries its secret in +// the fragment and is returned only at creation. +func (remote *transport) invites(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/v2/admin/invites" { + switch r.Method { + case http.MethodGet: + invites, err := remote.identity.Invites(r.Context()) + if err != nil { + identityError(w, err) + return + } + now := time.Now() + values := make([]inviteJSON, 0, len(invites)) + for _, invite := range invites { + values = append(values, projectInvite(invite, now)) + } + writeJSON(w, 200, struct { + Invites []inviteJSON `json:"invites"` + }{values}) + case http.MethodPost: + var input struct { + Email string `json:"email"` + } + if !decodeJSON(w, r, &input) { + return + } + invite, secret, err := remote.identity.CreateInvite(r.Context(), input.Email) + switch { + case errors.Is(err, identityservice.ErrGoogleDisabled): + problem(w, 409, "Google sign-in is not configured; invites need it") + return + case errors.Is(err, identityservice.ErrConflict): + problem(w, 409, "An account already uses this email") + return + case err != nil: + identityError(w, err) + return + } + writeJSON(w, 201, struct { + Invite inviteJSON `json:"invite"` + URL string `json:"url"` + }{projectInvite(invite, invite.CreatedAt), strings.TrimSuffix(remote.settings.PublicURL, "/") + "/invite#t=" + secret}) + default: + problem(w, 405, "Method not allowed") + } + return + } + id := strings.TrimPrefix(r.URL.Path, "/api/v2/admin/invites/") + if id == "" || strings.Contains(id, "/") { + http.NotFound(w, r) + return + } + if r.Method != http.MethodDelete { + problem(w, 405, "Method not allowed") + return + } + if err := remote.identity.RevokeInvite(r.Context(), id); err != nil { + identityError(w, err) + return + } + w.WriteHeader(204) +} + // admin serves account administration to the admin's browser session. Disabling // or deleting an account stops its active services; its credentials are already gone. func (remote *transport) admin(w http.ResponseWriter, r *http.Request, p principal) { + if strings.HasPrefix(r.URL.Path, "/api/v2/admin/invites") { + remote.invites(w, r) + return + } if r.URL.Path == "/api/v2/admin/users" { if r.Method != http.MethodGet { problem(w, 405, "Method not allowed") diff --git a/internal/remoteserver/admin_test.go b/internal/remoteserver/admin_test.go index b4afa87c..13dc5ea4 100644 --- a/internal/remoteserver/admin_test.go +++ b/internal/remoteserver/admin_test.go @@ -62,7 +62,7 @@ func TestAdminRoutesRequireAdminBrowserSession(t *testing.T) { for _, test := range []struct { method, path string want int - }{{"POST", "/api/v2/admin/users/missing/disable", 404}, {"GET", "/api/v2/admin/users/" + memberID + "/disable", 405}, {"POST", "/api/v2/admin/users/" + memberID + "/promote", 404}, {"POST", "/api/v2/admin/invites", 404}} { + }{{"POST", "/api/v2/admin/users/missing/disable", 404}, {"GET", "/api/v2/admin/users/" + memberID + "/disable", 405}, {"POST", "/api/v2/admin/users/" + memberID + "/promote", 404}, {"POST", "/api/v2/admin/settings", 404}} { if w := identityRequest(handler, test.method, test.path, "", cookie, csrf, ""); w.Code != test.want { t.Fatalf("%s %s: %d want %d", test.method, test.path, w.Code, test.want) } diff --git a/internal/remoteserver/google.go b/internal/remoteserver/google.go new file mode 100644 index 00000000..1be4fd77 --- /dev/null +++ b/internal/remoteserver/google.go @@ -0,0 +1,121 @@ +package remoteserver + +import ( + "errors" + "log" + "net/http" + "time" + + "github.com/flexdinesh/diffx/internal/identityservice" +) + +const ( + googleCallback = "/api/v2/auth/google/callback" + // flowCookie binds a Google authorization to the browser that started it, + // so a forged callback cannot sign a victim into another account. + flowCookie = "diffx_google_flow" +) + +// googleStart begins Google sign-in, optionally from an invite link. It is an +// authentication form: the caller already bounded admission. +func (remote *transport) googleStart(w http.ResponseWriter, r *http.Request) { + var invite string + if r.URL.Path == "/api/v2/invites/google" { + var input struct { + Token string `json:"token"` + } + if !decodeJSON(w, r, &input) { + return + } + invite = input.Token + } else if !decodeJSON(w, r, &struct{}{}) { + return + } + authorize, binding, err := remote.identity.StartGoogle(r.Context(), invite) + if err != nil { + googleError(w, r, err) + return + } + http.SetCookie(w, &http.Cookie{Name: flowCookie, Value: binding, Path: googleCallback, MaxAge: int(identityservice.GoogleFlowLifetime / time.Second), + HttpOnly: true, Secure: !remote.settings.InsecureCookies, SameSite: http.SameSiteLaxMode}) + writeJSON(w, 200, struct { + AuthorizeURL string `json:"authorizeUrl"` + }{authorize}) +} + +// inviteLookup resolves an invite secret for the invite page. The secret comes +// in the body so it never appears in request logs. +func (remote *transport) inviteLookup(w http.ResponseWriter, r *http.Request) { + var input struct { + Token string `json:"token"` + } + if !decodeJSON(w, r, &input) { + return + } + invite, err := remote.identity.PendingInvite(r.Context(), input.Token) + if err != nil { + googleError(w, r, err) + return + } + writeJSON(w, 200, struct { + Email string `json:"email"` + ExpiresAt int64 `json:"expiresAt"` + }{invite.Email, invite.ExpiresAt.UnixMilli()}) +} + +func googleError(w http.ResponseWriter, r *http.Request, err error) { + switch { + case errors.Is(err, identityservice.ErrGoogleDisabled): + http.NotFound(w, r) + case errors.Is(err, identityservice.ErrNotFound): + problem(w, 404, "Invite link is invalid, expired or already used") + default: + identityError(w, err) + } +} + +// googleCallbackRoute completes Google sign-in. It is a cross-site top-level +// navigation, so it runs before the same-origin check; the state and the flow +// cookie authenticate it. Every outcome redirects to the app. +func (remote *transport) googleCallbackRoute(w http.ResponseWriter, r *http.Request) { + if !remote.identity.GoogleEnabled() { + http.NotFound(w, r) + return + } + if r.Method != http.MethodGet { + problem(w, 405, "Method not allowed") + return + } + http.SetCookie(w, &http.Cookie{Name: flowCookie, Path: googleCallback, MaxAge: -1, HttpOnly: true, Secure: !remote.settings.InsecureCookies, SameSite: http.SameSiteLaxMode}) + query := r.URL.Query() + if query.Get("error") != "" || query.Get("code") == "" { + http.Redirect(w, r, "/?signin=cancelled", http.StatusSeeOther) + return + } + var binding string + if cookie, err := r.Cookie(flowCookie); err == nil { + binding = cookie.Value + } + session, err := remote.identity.CompleteGoogle(r.Context(), query.Get("state"), binding, query.Get("code")) + outcome := "" + switch { + case err == nil: + remote.cookie(w, session.Secret, session.ExpiresAt) + case errors.Is(err, identityservice.ErrNoAccount): + outcome = "no-account" + case errors.Is(err, identityservice.ErrInviteMismatch): + outcome = "invite-mismatch" + case errors.Is(err, identityservice.ErrNotFound): + outcome = "invite-invalid" + default: + if !errors.Is(err, identityservice.ErrAuthentication) { + log.Printf("Google sign-in: %v", err) + } + outcome = "failed" + } + target := "/" + if outcome != "" { + target = "/?signin=" + outcome + } + http.Redirect(w, r, target, http.StatusSeeOther) +} diff --git a/internal/remoteserver/google_test.go b/internal/remoteserver/google_test.go new file mode 100644 index 00000000..6ad068af --- /dev/null +++ b/internal/remoteserver/google_test.go @@ -0,0 +1,308 @@ +package remoteserver + +import ( + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync" + "testing" + "time" + + "github.com/flexdinesh/diffx/internal/testsupport" +) + +// fakeGoogle issues ID tokens for codes; claims per code are set by the test. +// It echoes the nonce from the authorization a code was issued for. +type fakeGoogle struct { + mu sync.Mutex + server *httptest.Server + claims map[string]map[string]any +} + +func newFakeGoogle(t *testing.T) *fakeGoogle { + t.Helper() + fake := &fakeGoogle{claims: make(map[string]map[string]any)} + fake.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil || r.PostForm.Get("client_secret") != "google-secret" || r.PostForm.Get("code_verifier") == "" { + w.WriteHeader(http.StatusBadRequest) + return + } + fake.mu.Lock() + claims, ok := fake.claims[r.PostForm.Get("code")] + fake.mu.Unlock() + if !ok { + w.WriteHeader(http.StatusBadRequest) + return + } + payload, err := json.Marshal(claims) + if err != nil { + t.Error(err) + } + _ = json.NewEncoder(w).Encode(map[string]string{"id_token": "e30." + base64.RawURLEncoding.EncodeToString(payload) + ".sig"}) + })) + t.Cleanup(fake.server.Close) + return fake +} + +func (fake *fakeGoogle) settings() *GoogleSettings { + return &GoogleSettings{ClientID: "google-client", ClientSecret: "google-secret", authorizeURL: fake.server.URL + "/auth", tokenURL: fake.server.URL + "/token"} +} + +// authorize records what Google would return for the started flow: a code +// whose ID token carries this subject and email and the flow's nonce. +func (fake *fakeGoogle) authorize(t *testing.T, authorizeURL, code, subject, email string, verified bool) string { + t.Helper() + parsed, err := url.Parse(authorizeURL) + if err != nil { + t.Fatal(err) + } + query := parsed.Query() + if query.Get("redirect_uri") != "https://diffx.example.com"+googleCallback || query.Get("client_id") != "google-client" { + t.Fatalf("authorize URL: %s", authorizeURL) + } + fake.mu.Lock() + fake.claims[code] = map[string]any{"iss": "https://accounts.google.com", "aud": "google-client", "exp": time.Now().Add(time.Hour).Unix(), + "nonce": query.Get("nonce"), "sub": subject, "email": email, "email_verified": verified} + fake.mu.Unlock() + return query.Get("state") +} + +// startGoogle begins sign-in and returns the authorization URL and flow cookie. +func startGoogle(t *testing.T, handler http.Handler, path, body string) (string, *http.Cookie) { + t.Helper() + w := identityRequest(handler, "POST", path, body, nil, "", "") + if w.Code != 200 { + t.Fatalf("start %s: %d %s", path, w.Code, w.Body.String()) + } + var started struct { + AuthorizeURL string `json:"authorizeUrl"` + } + if err := json.Unmarshal(w.Body.Bytes(), &started); err != nil { + t.Fatal(err) + } + for _, cookie := range w.Result().Cookies() { + if cookie.Name == flowCookie { + if !cookie.HttpOnly || cookie.Path != googleCallback || cookie.SameSite != http.SameSiteLaxMode { + t.Fatalf("flow cookie: %+v", cookie) + } + return started.AuthorizeURL, cookie + } + } + t.Fatal("flow cookie missing") + return "", nil +} + +// callback follows Google's cross-site redirect back to diffx. +func callback(handler http.Handler, state, code string, flow *http.Cookie) *httptest.ResponseRecorder { + r := httptest.NewRequest("GET", googleCallback+"?"+url.Values{"state": {state}, "code": {code}}.Encode(), nil) + r.Header.Set("Sec-Fetch-Site", "cross-site") + if flow != nil { + r.AddCookie(&http.Cookie{Name: flowCookie, Value: flow.Value}) + } + w := httptest.NewRecorder() + handler.ServeHTTP(w, r) + return w +} + +func sessionFrom(t *testing.T, w *httptest.ResponseRecorder) *http.Cookie { + t.Helper() + if w.Code != http.StatusSeeOther || w.Header().Get("Location") != "/" { + t.Fatalf("sign-in redirect: %d %s", w.Code, w.Header().Get("Location")) + } + for _, cookie := range w.Result().Cookies() { + if cookie.Name == sessionCookie && cookie.Value != "" { + return cookie + } + } + t.Fatal("session cookie missing") + return nil +} + +func createInvite(t *testing.T, handler http.Handler, cookie *http.Cookie, csrf, email string) string { + t.Helper() + w := identityRequest(handler, "POST", "/api/v2/admin/invites", `{"email":"`+email+`"}`, cookie, csrf, "") + if w.Code != 201 { + t.Fatalf("create invite: %d %s", w.Code, w.Body.String()) + } + var created struct { + Invite inviteJSON `json:"invite"` + URL string `json:"url"` + } + if err := json.Unmarshal(w.Body.Bytes(), &created); err != nil { + t.Fatal(err) + } + link, secret, found := strings.Cut(created.URL, "/invite#t=") + if !found || link != "https://diffx.example.com" || created.Invite.Status != "pending" || created.Invite.ExpiresAt-created.Invite.CreatedAt != (48*time.Hour).Milliseconds() { + t.Fatalf("invite: %+v", created) + } + return secret +} + +func TestInviteSignupThenGoogleSignInByStableSubject(t *testing.T) { + fake := newFakeGoogle(t) + store, handler := identityHandler(t, Settings{PublicURL: "https://diffx.example.com", Google: fake.settings()}) + testsupport.RemoteAdmin(t, store, "admin", testPassword) + admin, csrf := loginIdentity(t, handler, "admin") + if w := identityRequest(handler, "GET", "/api/v2/bootstrap", "", nil, "", ""); !strings.Contains(w.Body.String(), `"googleEnabled":true`) { + t.Fatalf("bootstrap: %s", w.Body.String()) + } + secret := createInvite(t, handler, admin, csrf, "Alice.Smith@Example.com") + + w := identityRequest(handler, "POST", "/api/v2/invites/lookup", `{"token":"`+secret+`"}`, nil, "", "") + if w.Code != 200 || !strings.Contains(w.Body.String(), `"email":"alice.smith@example.com"`) { + t.Fatalf("lookup: %d %s", w.Code, w.Body.String()) + } + authorize, flow := startGoogle(t, handler, "/api/v2/invites/google", `{"token":"`+secret+`"}`) + state := fake.authorize(t, authorize, "unverified", "subject-a", "alice.smith@example.com", false) + if w := callback(handler, state, "unverified", flow); w.Header().Get("Location") != "/?signin=invite-mismatch" { + t.Fatalf("unverified email accepted: %s", w.Header().Get("Location")) + } + authorize, flow = startGoogle(t, handler, "/api/v2/invites/google", `{"token":"`+secret+`"}`) + state = fake.authorize(t, authorize, "other-email", "subject-a", "mallory@example.com", true) + if w := callback(handler, state, "other-email", flow); w.Header().Get("Location") != "/?signin=invite-mismatch" { + t.Fatalf("other email accepted: %s", w.Header().Get("Location")) + } + authorize, flow = startGoogle(t, handler, "/api/v2/invites/google", `{"token":"`+secret+`"}`) + state = fake.authorize(t, authorize, "accept", "subject-a", "Alice.Smith@example.com", true) + if w := callback(handler, state, "accept", nil); w.Header().Get("Location") != "/?signin=failed" { + t.Fatalf("callback without flow cookie: %s", w.Header().Get("Location")) + } + authorize, flow = startGoogle(t, handler, "/api/v2/invites/google", `{"token":"`+secret+`"}`) + state = fake.authorize(t, authorize, "accept", "subject-a", "Alice.Smith@example.com", true) + member := sessionFrom(t, callback(handler, state, "accept", flow)) + if w := callback(handler, state, "accept", flow); w.Header().Get("Location") != "/?signin=failed" { + t.Fatalf("state reused: %s", w.Header().Get("Location")) + } + w = identityRequest(handler, "GET", "/api/v2/bootstrap", "", member, "", "") + if !strings.Contains(w.Body.String(), `"username":"alice.smith","admin":false`) { + t.Fatalf("member identity: %s", w.Body.String()) + } + if w := identityRequest(handler, "POST", "/api/v2/invites/lookup", `{"token":"`+secret+`"}`, nil, "", ""); w.Code != 404 { + t.Fatalf("used invite still pending: %d", w.Code) + } + if w := identityRequest(handler, "POST", "/api/v2/admin/invites", `{"email":"alice.smith@example.com"}`, admin, csrf, ""); w.Code != 409 { + t.Fatalf("invited an existing account's email: %d", w.Code) + } + + // The same Google account signs in after changing its email. + authorize, flow = startGoogle(t, handler, "/api/v2/auth/google", `{}`) + state = fake.authorize(t, authorize, "renamed", "subject-a", "alice@new.example.com", true) + again := sessionFrom(t, callback(handler, state, "renamed", flow)) + w = identityRequest(handler, "GET", "/api/v2/bootstrap", "", again, "", "") + if !strings.Contains(w.Body.String(), `"username":"alice.smith"`) { + t.Fatalf("email change changed account: %s", w.Body.String()) + } + w = identityRequest(handler, "GET", "/api/v2/admin/users", "", admin, "", "") + if !strings.Contains(w.Body.String(), `"email":"alice@new.example.com"`) { + t.Fatalf("email change not recorded: %s", w.Body.String()) + } + + // A Google account without an account or invite cannot sign up. + authorize, flow = startGoogle(t, handler, "/api/v2/auth/google", `{}`) + state = fake.authorize(t, authorize, "stranger", "subject-b", "stranger@example.com", true) + if w := callback(handler, state, "stranger", flow); w.Header().Get("Location") != "/?signin=no-account" { + t.Fatalf("uninvited Google account: %s", w.Header().Get("Location")) + } + if w := identityRequest(handler, "POST", "/api/v2/auth/login", `{"username":"alice.smith","password":""}`, nil, "", ""); w.Code != 401 { + t.Fatalf("member password login: %d", w.Code) + } +} + +func TestInviteAdministrationAndDisabledGoogleAccount(t *testing.T) { + fake := newFakeGoogle(t) + store, handler := identityHandler(t, Settings{PublicURL: "https://diffx.example.com", Google: fake.settings()}) + testsupport.RemoteAdmin(t, store, "admin", testPassword) + admin, csrf := loginIdentity(t, handler, "admin") + first := createInvite(t, handler, admin, csrf, "bob@example.com") + second := createInvite(t, handler, admin, csrf, "bob@example.com") + if w := identityRequest(handler, "POST", "/api/v2/invites/google", `{"token":"`+first+`"}`, nil, "", ""); w.Code != 404 { + t.Fatalf("replaced invite started sign-in: %d", w.Code) + } + w := identityRequest(handler, "GET", "/api/v2/admin/invites", "", admin, "", "") + var listed struct { + Invites []inviteJSON `json:"invites"` + } + if err := json.Unmarshal(w.Body.Bytes(), &listed); err != nil || len(listed.Invites) != 2 || listed.Invites[0].Status == listed.Invites[1].Status { + t.Fatalf("invites: %s %v", w.Body.String(), err) + } + if listed.Invites[0].Status != "pending" { + listed.Invites[0], listed.Invites[1] = listed.Invites[1], listed.Invites[0] + } + if listed.Invites[1].Status != "revoked" { + t.Fatalf("replaced invite: %+v", listed.Invites) + } + if strings.Contains(w.Body.String(), second) { + t.Fatal("invite listing leaks the secret") + } + memberCookie, memberCSRF := memberSession(t, store, "member") + for _, request := range []struct { + method, path, body string + }{{"GET", "/api/v2/admin/invites", ""}, {"POST", "/api/v2/admin/invites", `{"email":"x@example.com"}`}, {"DELETE", "/api/v2/admin/invites/" + listed.Invites[0].ID, ""}} { + if w := identityRequest(handler, request.method, request.path, request.body, memberCookie, memberCSRF, ""); w.Code != 403 { + t.Fatalf("member %s %s: %d", request.method, request.path, w.Code) + } + } + if w := identityRequest(handler, "POST", "/api/v2/admin/invites", `{"email":"not-an-email"}`, admin, csrf, ""); w.Code != 400 { + t.Fatalf("invalid email: %d", w.Code) + } + authorize, flow := startGoogle(t, handler, "/api/v2/invites/google", `{"token":"`+second+`"}`) + if w := identityRequest(handler, "DELETE", "/api/v2/admin/invites/"+listed.Invites[0].ID, "", admin, csrf, ""); w.Code != 204 { + t.Fatalf("revoke: %d", w.Code) + } + state := fake.authorize(t, authorize, "late", "subject-bob", "bob@example.com", true) + if w := callback(handler, state, "late", flow); w.Header().Get("Location") != "/?signin=invite-invalid" { + t.Fatalf("revoked invite accepted: %s", w.Header().Get("Location")) + } + + bob := createInvite(t, handler, admin, csrf, "bob@example.com") + authorize, flow = startGoogle(t, handler, "/api/v2/invites/google", `{"token":"`+bob+`"}`) + state = fake.authorize(t, authorize, "bob", "subject-bob", "bob@example.com", true) + sessionFrom(t, callback(handler, state, "bob", flow)) + w = identityRequest(handler, "GET", "/api/v2/admin/users", "", admin, "", "") + var users struct { + Users []adminUserJSON `json:"users"` + } + if err := json.Unmarshal(w.Body.Bytes(), &users); err != nil { + t.Fatal(err) + } + var bobID string + for _, user := range users.Users { + if user.Username == "bob" { + bobID = user.ID + } + } + if w := identityRequest(handler, "POST", "/api/v2/admin/users/"+bobID+"/disable", "", admin, csrf, ""); w.Code != 204 { + t.Fatalf("disable: %d", w.Code) + } + authorize, flow = startGoogle(t, handler, "/api/v2/auth/google", `{}`) + state = fake.authorize(t, authorize, "disabled", "subject-bob", "bob@example.com", true) + if w := callback(handler, state, "disabled", flow); w.Header().Get("Location") != "/?signin=failed" { + t.Fatalf("disabled account signed in: %s", w.Header().Get("Location")) + } +} + +func TestGoogleRoutesAbsentWithoutConfiguration(t *testing.T) { + store, handler := identityHandler(t, Settings{}) + testsupport.RemoteAdmin(t, store, "admin", testPassword) + admin, csrf := loginIdentity(t, handler, "admin") + for _, path := range []string{"/api/v2/auth/google", "/api/v2/invites/google", "/api/v2/invites/lookup"} { + if w := identityRequest(handler, "POST", path, `{"token":"`+strings.Repeat("x", 43)+`"}`, nil, "", ""); w.Code != 404 && w.Code != 400 { + t.Fatalf("unconfigured %s: %d", path, w.Code) + } + } + if w := identityRequest(handler, "POST", "/api/v2/auth/google", `{}`, nil, "", ""); w.Code != 404 { + t.Fatalf("unconfigured Google start: %d", w.Code) + } + if w := callback(handler, "state", "code", nil); w.Code != 404 { + t.Fatalf("unconfigured callback: %d", w.Code) + } + if w := identityRequest(handler, "POST", "/api/v2/admin/invites", `{"email":"a@example.com"}`, admin, csrf, ""); w.Code != 409 { + t.Fatalf("invite without Google: %d", w.Code) + } + if w := identityRequest(handler, "GET", "/api/v2/admin/invites", "", admin, "", ""); w.Code != 200 { + t.Fatalf("invite listing without Google: %d", w.Code) + } +} diff --git a/internal/remoteserver/identity.go b/internal/remoteserver/identity.go index 738a0e70..c8bdc5ff 100644 --- a/internal/remoteserver/identity.go +++ b/internal/remoteserver/identity.go @@ -51,7 +51,7 @@ type bootstrapJSON struct { } func (remote *transport) bootstrap(w http.ResponseWriter, p principal) { - result := bootstrapJSON{Mode: "remote"} + result := bootstrapJSON{Mode: "remote", GoogleEnabled: remote.identity.GoogleEnabled()} if p.session != nil { result.User = &accountJSON{p.account.ID, p.account.Username, p.account.Admin} result.CSRFToken = &p.session.CSRFToken @@ -84,13 +84,18 @@ func (remote *transport) ServeHTTP(w http.ResponseWriter, r *http.Request) { if remote.githubPublic(w, r) { return } + if r.URL.Path == googleCallback { + remote.googleCallbackRoute(w, r) + return + } if !serverapp.SameOrigin(r) { problem(w, 403, "Cross-origin access denied") return } // Login remains available with an expired browser cookie. Explicit bearer // credentials are always validated; never silently fall back to cookies. - authForm := r.URL.Path == "/api/v2/auth/login" || r.URL.Path == "/api/v2/setup" + authForm := r.URL.Path == "/api/v2/auth/login" || r.URL.Path == "/api/v2/setup" || r.URL.Path == "/api/v2/auth/google" || + r.URL.Path == "/api/v2/invites/lookup" || r.URL.Path == "/api/v2/invites/google" public := r.URL.Path == "/api/v2/bootstrap" || (!strings.HasPrefix(r.URL.Path, "/api/") && !strings.HasPrefix(r.URL.Path, "/mcp") && !strings.HasPrefix(r.URL.Path, "/control/")) p, err := remote.authenticate(r) if errors.Is(err, identityservice.ErrAuthentication) && r.Header.Get("Authorization") == "" && (public || authForm) { @@ -256,8 +261,9 @@ func (remote *transport) static(w http.ResponseWriter, r *http.Request) { } } -// auth serves the credential forms: admin password login and one-time admin -// setup. Both are bounded like login because each verifies a secret. +// auth serves the credential forms: admin password login, one-time admin setup, +// Google sign-in starts and invite lookups. All are bounded like login because +// each verifies a secret or starts a sign-in. func (remote *transport) auth(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/api/v2/setup" && r.Method == http.MethodGet { required, err := remote.identity.SetupRequired(r.Context()) @@ -287,6 +293,14 @@ func (remote *transport) auth(w http.ResponseWriter, r *http.Request) { problem(w, 429, "Too many authentication attempts") return } + switch r.URL.Path { + case "/api/v2/auth/google", "/api/v2/invites/google": + remote.googleStart(w, r) + return + case "/api/v2/invites/lookup": + remote.inviteLookup(w, r) + return + } var session identityservice.Session var err error if r.URL.Path == "/api/v2/setup" { diff --git a/internal/remoteserver/server.go b/internal/remoteserver/server.go index a4808734..55259d30 100644 --- a/internal/remoteserver/server.go +++ b/internal/remoteserver/server.go @@ -13,6 +13,7 @@ import ( "time" "github.com/flexdinesh/diffx/internal/contextservice" + "github.com/flexdinesh/diffx/internal/google" "github.com/flexdinesh/diffx/internal/identityservice" "github.com/flexdinesh/diffx/internal/ingestion" "github.com/flexdinesh/diffx/internal/ingestionqueue" @@ -30,6 +31,28 @@ type Settings struct { InsecureCookies bool RetentionDays int GitHub *GitHubSettings + // Google enables Google sign-in and invites; it requires PublicURL. + Google *GoogleSettings +} + +type GoogleSettings struct { + ClientID string + ClientSecret string + // authorizeURL and tokenURL replace Google in tests. + authorizeURL, tokenURL string +} + +// composeIdentity composes account rules with Google sign-in when configured. +func composeIdentity(store *reviewstore.Store, settings Settings) *identityservice.Service { + if settings.Google == nil { + return identityservice.New(store) + } + var options []google.Option + if settings.Google.tokenURL != "" { + options = append(options, google.WithEndpoints(settings.Google.authorizeURL, settings.Google.tokenURL)) + } + redirect := strings.TrimSuffix(settings.PublicURL, "/") + googleCallback + return identityservice.New(store, identityservice.WithGoogle(google.New(settings.Google.ClientID, settings.Google.ClientSecret, redirect, options...))) } type application struct { @@ -119,7 +142,7 @@ func MultiHandler(ctx context.Context, store *reviewstore.Store, assets fs.FS, o // compose returns the public handler and, when GitHub is configured, the // internal fetcher handler. func compose(ctx context.Context, store *reviewstore.Store, assets fs.FS, settings Settings) (http.Handler, http.Handler, func() error, []serverapp.Task, error) { - identity := identityservice.New(store) + identity := composeIdentity(store, settings) github, err := composeGitHub(store, settings.GitHub) if err != nil { return nil, nil, nil, nil, err diff --git a/internal/reviewstore/invites.go b/internal/reviewstore/invites.go new file mode 100644 index 00000000..64199c85 --- /dev/null +++ b/internal/reviewstore/invites.go @@ -0,0 +1,179 @@ +package reviewstore + +import ( + "context" + "database/sql" + "errors" + "time" + + "github.com/flexdinesh/diffx/internal/google" + "github.com/flexdinesh/diffx/internal/identityservice" +) + +// CreateInvite replaces the email's pending invites, refusing an email an +// account already uses, in one transaction. +func (store *Store) CreateInvite(ctx context.Context, invite identityservice.Invite, tokenHash string) error { + transaction, err := store.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer transaction.Rollback() + var linked bool + if err := transaction.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM google_identities WHERE email=?)`, invite.Email).Scan(&linked); err != nil { + return err + } + if linked { + return identityservice.ErrConflict + } + if _, err := transaction.ExecContext(ctx, `UPDATE account_invites SET revoked_at=? WHERE email=? AND used_at IS NULL AND revoked_at IS NULL`, invite.CreatedAt.UnixMilli(), invite.Email); err != nil { + return err + } + if _, err := transaction.ExecContext(ctx, `INSERT INTO account_invites(id,token_hash,email,created_at,expires_at) VALUES(?,?,?,?,?)`, + invite.ID, tokenHash, invite.Email, invite.CreatedAt.UnixMilli(), invite.ExpiresAt.UnixMilli()); err != nil { + return err + } + return transaction.Commit() +} + +const inviteColumns = `i.id,i.email,i.created_at,i.expires_at,i.used_at,COALESCE(a.username,''),i.revoked_at FROM account_invites i LEFT JOIN remote_accounts a ON a.user_id=i.used_by` + +type rowScanner interface{ Scan(...any) error } + +func scanInvite(row rowScanner) (identityservice.Invite, error) { + var invite identityservice.Invite + var created, expires int64 + var used, revoked sql.NullInt64 + if err := row.Scan(&invite.ID, &invite.Email, &created, &expires, &used, &invite.UsedBy, &revoked); err != nil { + return invite, err + } + invite.CreatedAt, invite.ExpiresAt = time.UnixMilli(created), time.UnixMilli(expires) + if used.Valid { + at := time.UnixMilli(used.Int64) + invite.UsedAt = &at + } + if revoked.Valid { + at := time.UnixMilli(revoked.Int64) + invite.RevokedAt = &at + } + return invite, nil +} + +func (store *Store) Invites(ctx context.Context) ([]identityservice.Invite, error) { + rows, err := store.db.QueryContext(ctx, `SELECT `+inviteColumns+` ORDER BY i.created_at DESC,i.id`) + if err != nil { + return nil, err + } + defer rows.Close() + invites := make([]identityservice.Invite, 0) + for rows.Next() { + invite, err := scanInvite(rows) + if err != nil { + return nil, err + } + invites = append(invites, invite) + } + return invites, rows.Err() +} + +func (store *Store) Invite(ctx context.Context, id string) (identityservice.Invite, error) { + invite, err := scanInvite(store.db.QueryRowContext(ctx, `SELECT `+inviteColumns+` WHERE i.id=?`, id)) + if errors.Is(err, sql.ErrNoRows) { + return invite, identityservice.ErrNotFound + } + return invite, err +} + +func (store *Store) PendingInvite(ctx context.Context, tokenHash string, now time.Time) (identityservice.Invite, error) { + invite, err := scanInvite(store.db.QueryRowContext(ctx, `SELECT `+inviteColumns+` WHERE i.token_hash=? AND i.used_at IS NULL AND i.revoked_at IS NULL AND i.expires_at>?`, tokenHash, now.UnixMilli())) + if errors.Is(err, sql.ErrNoRows) { + return invite, identityservice.ErrNotFound + } + return invite, err +} + +// RevokeInvite revokes a pending invite; used, revoked or unknown invites are ErrNotFound. +func (store *Store) RevokeInvite(ctx context.Context, id string, at time.Time) error { + result, err := store.db.ExecContext(ctx, `UPDATE account_invites SET revoked_at=? WHERE id=? AND used_at IS NULL AND revoked_at IS NULL`, at.UnixMilli(), id) + if err != nil { + return err + } + count, err := result.RowsAffected() + if err != nil { + return err + } + if count != 1 { + return identityservice.ErrNotFound + } + return nil +} + +// AcceptInvite uses the pending invite in the account's transaction, so two +// acceptances create one account. +func (store *Store) AcceptInvite(ctx context.Context, inviteID string, account identityservice.Account, identity google.Identity, now time.Time) error { + transaction, err := store.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer transaction.Rollback() + if err := insertAccount(ctx, transaction, account, "", now); err != nil { + return err + } + result, err := transaction.ExecContext(ctx, `UPDATE account_invites SET used_at=?,used_by=? WHERE id=? AND used_at IS NULL AND revoked_at IS NULL AND expires_at>? + AND NOT EXISTS(SELECT 1 FROM google_identities WHERE sub=?)`, now.UnixMilli(), account.ID, inviteID, now.UnixMilli(), identity.Subject) + if err != nil { + return err + } + count, err := result.RowsAffected() + if err != nil { + return err + } + if count != 1 { + return identityservice.ErrNotFound + } + if _, err := transaction.ExecContext(ctx, `INSERT INTO google_identities(sub,user_id,email,updated_at) VALUES(?,?,?,?)`, identity.Subject, account.ID, identity.Email, now.UnixMilli()); err != nil { + return err + } + return transaction.Commit() +} + +func (store *Store) GoogleAccount(ctx context.Context, identity google.Identity, now time.Time) (identityservice.Account, bool, error) { + var account identityservice.Account + var disabled bool + err := store.db.QueryRowContext(ctx, `SELECT `+accountColumns+`,NOT `+enabled+` FROM google_identities g JOIN remote_accounts a ON a.user_id=g.user_id WHERE g.sub=?`, identity.Subject). + Scan(&account.ID, &account.Username, &account.Admin, &disabled) + if errors.Is(err, sql.ErrNoRows) { + return account, false, identityservice.ErrNotFound + } + if err != nil { + return account, false, err + } + _, err = store.db.ExecContext(ctx, `UPDATE google_identities SET email=?,updated_at=? WHERE sub=? AND email<>?`, identity.Email, now.UnixMilli(), identity.Subject, identity.Email) + return account, disabled, err +} + +func (store *Store) PutGoogleFlow(ctx context.Context, stateHash string, flow identityservice.GoogleFlow) error { + // Expired flows are removed when new ones start; consumed flows are deleted. + if _, err := store.db.ExecContext(ctx, `DELETE FROM google_auth_states WHERE expires_at<=?`, time.Now().UnixMilli()); err != nil { + return err + } + var invite any + if flow.InviteID != "" { + invite = flow.InviteID + } + _, err := store.db.ExecContext(ctx, `INSERT INTO google_auth_states(state_hash,binding_hash,nonce,pkce_verifier,invite_id,expires_at) VALUES(?,?,?,?,?,?)`, + stateHash, flow.BindingHash, flow.Nonce, flow.Verifier, invite, flow.ExpiresAt.UnixMilli()) + return err +} + +func (store *Store) TakeGoogleFlow(ctx context.Context, stateHash string, now time.Time) (identityservice.GoogleFlow, error) { + var flow identityservice.GoogleFlow + var invite sql.NullString + var expires int64 + err := store.db.QueryRowContext(ctx, `DELETE FROM google_auth_states WHERE state_hash=? AND expires_at>? RETURNING binding_hash,nonce,pkce_verifier,invite_id,expires_at`, stateHash, now.UnixMilli()). + Scan(&flow.BindingHash, &flow.Nonce, &flow.Verifier, &invite, &expires) + if errors.Is(err, sql.ErrNoRows) { + return flow, identityservice.ErrNotFound + } + flow.InviteID, flow.ExpiresAt = invite.String, time.UnixMilli(expires) + return flow, err +} diff --git a/internal/reviewstore/invites_test.go b/internal/reviewstore/invites_test.go new file mode 100644 index 00000000..da8b84ce --- /dev/null +++ b/internal/reviewstore/invites_test.go @@ -0,0 +1,143 @@ +package reviewstore + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "github.com/flexdinesh/diffx/internal/google" + "github.com/flexdinesh/diffx/internal/identityservice" +) + +func invite(t *testing.T, store *Store, id, email string, now time.Time) identityservice.Invite { + t.Helper() + value := identityservice.Invite{ID: id, Email: email, CreatedAt: now, ExpiresAt: now.Add(identityservice.InviteLifetime)} + if err := store.CreateInvite(t.Context(), value, "hash-"+id); err != nil { + t.Fatal(err) + } + return value +} + +func TestInviteReplacementAcceptanceAndRevocation(t *testing.T) { + store, _ := testIdentity(t) + ctx := context.Background() + now := time.Now() + first := invite(t, store, "first", "a@example.com", now) + second := invite(t, store, "second", "a@example.com", now.Add(time.Second)) + if replaced, err := store.Invite(ctx, first.ID); err != nil || replaced.Status(now) != identityservice.InviteRevoked { + t.Fatalf("re-invite did not replace pending invite: %+v %v", replaced, err) + } + if _, err := store.PendingInvite(ctx, "hash-first", now); !errors.Is(err, identityservice.ErrNotFound) { + t.Fatalf("replaced invite pending: %v", err) + } + if pending, err := store.PendingInvite(ctx, "hash-second", now); err != nil || pending.ID != second.ID { + t.Fatalf("pending invite: %+v %v", pending, err) + } + if _, err := store.PendingInvite(ctx, "hash-second", second.ExpiresAt); !errors.Is(err, identityservice.ErrNotFound) { + t.Fatalf("expired invite pending: %v", err) + } + identity := google.Identity{Subject: "subject-a", Email: "a@example.com", EmailVerified: true} + var workers sync.WaitGroup + results := make(chan error, 4) + for index := range 4 { + workers.Go(func() { + account := identityservice.Account{ID: "account-" + string(rune('a'+index)), Username: "user-" + string(rune('a'+index))} + results <- store.AcceptInvite(ctx, second.ID, account, identity, now) + }) + } + workers.Wait() + close(results) + var accepted int + for err := range results { + if err == nil { + accepted++ + } else if !errors.Is(err, identityservice.ErrNotFound) { + t.Fatal(err) + } + } + accounts, err := store.IdentityAccounts(ctx) + if accepted != 1 || err != nil || len(accounts) != 1 { + t.Fatalf("concurrent acceptance: %d %+v %v", accepted, accounts, err) + } + used, err := store.Invite(ctx, second.ID) + if err != nil || used.Status(now) != identityservice.InviteUsed || used.UsedBy != accounts[0].Username { + t.Fatalf("used invite: %+v %v", used, err) + } + if err := store.CreateInvite(ctx, identityservice.Invite{ID: "third", Email: "a@example.com", CreatedAt: now, ExpiresAt: now.Add(time.Hour)}, "hash-third"); !errors.Is(err, identityservice.ErrConflict) { + t.Fatalf("invited an email an account uses: %v", err) + } + if err := store.RevokeInvite(ctx, second.ID, now); !errors.Is(err, identityservice.ErrNotFound) { + t.Fatalf("revoked a used invite: %v", err) + } + other := invite(t, store, "other", "b@example.com", now) + if err := store.RevokeInvite(ctx, other.ID, now); err != nil { + t.Fatal(err) + } + if err := store.AcceptInvite(ctx, other.ID, identityservice.Account{ID: "account-revoked", Username: "bob"}, google.Identity{Subject: "subject-b", Email: "b@example.com"}, now); !errors.Is(err, identityservice.ErrNotFound) { + t.Fatalf("revoked invite accepted: %v", err) + } + if again := invite(t, store, "again", "c@example.com", now); store.AcceptInvite(ctx, again.ID, identityservice.Account{ID: "account-linked", Username: "carol"}, identity, now) == nil { + t.Fatal("a linked Google subject created a second account") + } +} + +func TestGoogleAccountFollowsEmailChangesAndDisable(t *testing.T) { + store, service := testIdentity(t) + ctx := context.Background() + now := time.Now() + value := invite(t, store, "invite", "old@example.com", now) + account := identityservice.Account{ID: "account", Username: "old"} + if err := store.AcceptInvite(ctx, value.ID, account, google.Identity{Subject: "subject", Email: "old@example.com", EmailVerified: true}, now); err != nil { + t.Fatal(err) + } + resolved, disabled, err := store.GoogleAccount(ctx, google.Identity{Subject: "subject", Email: "new@example.com"}, now) + if err != nil || disabled || resolved != account { + t.Fatalf("google account: %+v %v %v", resolved, disabled, err) + } + summaries, err := service.AccountSummaries(ctx) + if err != nil || len(summaries) != 1 || summaries[0].Email != "new@example.com" { + t.Fatalf("email change not recorded: %+v %v", summaries, err) + } + if _, _, err := store.GoogleAccount(ctx, google.Identity{Subject: "other", Email: "new@example.com"}, now); !errors.Is(err, identityservice.ErrNotFound) { + t.Fatalf("email matched another subject: %v", err) + } + if err := service.DisableAccount(ctx, account.ID); err != nil { + t.Fatal(err) + } + if _, disabled, err := store.GoogleAccount(ctx, google.Identity{Subject: "subject", Email: "new@example.com"}, now); err != nil || !disabled { + t.Fatalf("disabled google account: %v %v", disabled, err) + } + if err := service.DeleteAccount(ctx, account.ID); err != nil { + t.Fatal(err) + } + if _, _, err := store.GoogleAccount(ctx, google.Identity{Subject: "subject"}, now); !errors.Is(err, identityservice.ErrNotFound) { + t.Fatalf("deleted account kept its Google identity: %v", err) + } + if used, err := store.Invite(ctx, value.ID); err != nil || used.Status(now) != identityservice.InviteUsed || used.UsedBy != "" { + t.Fatalf("invite after account deletion: %+v %v", used, err) + } +} + +func TestGoogleFlowIsConsumedOnce(t *testing.T) { + store, _ := testIdentity(t) + ctx := context.Background() + now := time.Now() + flow := identityservice.GoogleFlow{BindingHash: "binding", Nonce: "nonce", Verifier: "verifier", ExpiresAt: now.Add(time.Minute)} + if err := store.PutGoogleFlow(ctx, "state", flow); err != nil { + t.Fatal(err) + } + if err := store.PutGoogleFlow(ctx, "expired", identityservice.GoogleFlow{BindingHash: "b", Nonce: "n", Verifier: "v", ExpiresAt: now}); err != nil { + t.Fatal(err) + } + taken, err := store.TakeGoogleFlow(ctx, "state", now) + if err != nil || taken.BindingHash != "binding" || taken.Verifier != "verifier" || taken.InviteID != "" { + t.Fatalf("take flow: %+v %v", taken, err) + } + for _, state := range []string{"state", "expired", "unknown"} { + if _, err := store.TakeGoogleFlow(ctx, state, now); !errors.Is(err, identityservice.ErrNotFound) { + t.Fatalf("flow %s taken: %v", state, err) + } + } +} diff --git a/packages/api/openapi.yaml b/packages/api/openapi.yaml index 2ad1625d..f80a3530 100644 --- a/packages/api/openapi.yaml +++ b/packages/api/openapi.yaml @@ -101,6 +101,108 @@ paths: $ref: "#/components/responses/Problem" default: $ref: "#/components/responses/Problem" + /api/v2/auth/google: + post: + operationId: startGoogleSignIn + description: Distributed only, when Google sign-in is configured (otherwise 404). Start Google sign-in for an existing account. Sets an HttpOnly flow cookie binding the authorization to this browser; navigate to `authorizeUrl` within 10 minutes. + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: false + responses: + "200": + $ref: "#/components/responses/GoogleAuthorization" + "400": + $ref: "#/components/responses/Problem" + "403": + $ref: "#/components/responses/Problem" + "404": + $ref: "#/components/responses/Problem" + "429": + $ref: "#/components/responses/Problem" + default: + $ref: "#/components/responses/Problem" + /api/v2/auth/google/callback: + get: + operationId: completeGoogleSignIn + description: Distributed only. Google redirects the browser here; the state and the flow cookie authenticate it, so it accepts cross-site navigation. Every outcome redirects to the app; failures add `?signin=` with `cancelled`, `no-account`, `invite-mismatch`, `invite-invalid` or `failed`. A Google account is identified by its subject, so a changed email keeps the account. + parameters: + - { in: query, name: state, required: false, schema: { type: string } } + - { in: query, name: code, required: false, schema: { type: string } } + - { in: query, name: error, required: false, schema: { type: string } } + responses: + "303": + description: Redirect to the app, signed in on success. + headers: + Location: + required: true + schema: + type: string + "404": + $ref: "#/components/responses/Problem" + default: + $ref: "#/components/responses/Problem" + /api/v2/invites/lookup: + post: + operationId: lookupInvite + description: Distributed only, when Google sign-in is configured. Resolve a pending invite from its link's `#t=` secret. The secret travels in the body so it stays out of request logs. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/InviteToken" + responses: + "200": + description: Pending invite. + content: + application/json: + schema: + type: object + additionalProperties: false + required: [email, expiresAt] + properties: + email: + type: string + expiresAt: + type: integer + format: int64 + "400": + $ref: "#/components/responses/Problem" + "403": + $ref: "#/components/responses/Problem" + "404": + $ref: "#/components/responses/Problem" + "429": + $ref: "#/components/responses/Problem" + default: + $ref: "#/components/responses/Problem" + /api/v2/invites/google: + post: + operationId: startInviteGoogleSignIn + description: Distributed only, when Google sign-in is configured. Start Google sign-in that accepts a pending invite. The callback creates the account only when Google reports a verified email equal to the invite's; the invite is used once. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/InviteToken" + responses: + "200": + $ref: "#/components/responses/GoogleAuthorization" + "400": + $ref: "#/components/responses/Problem" + "403": + $ref: "#/components/responses/Problem" + "404": + $ref: "#/components/responses/Problem" + "429": + $ref: "#/components/responses/Problem" + default: + $ref: "#/components/responses/Problem" /api/v2/auth/logout: post: security: @@ -351,6 +453,99 @@ paths: $ref: "#/components/responses/Problem" default: $ref: "#/components/responses/Problem" + /api/v2/admin/invites: + get: + security: + - browserSession: [] + operationId: listInvites + description: Every invite, newest first. Never exposes invite secrets. Admin browser session only. + responses: + "200": + description: Invites. + content: + application/json: + schema: + type: object + additionalProperties: false + required: [invites] + properties: + invites: + type: array + items: + $ref: "#/components/schemas/Invite" + "401": + $ref: "#/components/responses/Problem" + "403": + $ref: "#/components/responses/Problem" + default: + $ref: "#/components/responses/Problem" + post: + security: + - browserSession: [] + operationId: createInvite + description: Invite an email to sign up with Google. Replaces the email's pending invite. The link is returned only here and expires after 48 hours. 409 when Google sign-in is not configured or an account already uses the email. Admin browser session only. + parameters: + - $ref: "#/components/parameters/CsrfToken" + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: false + required: [email] + properties: + email: + type: string + responses: + "201": + description: Created invite and its one-time link. + content: + application/json: + schema: + type: object + additionalProperties: false + required: [invite, url] + properties: + invite: + $ref: "#/components/schemas/Invite" + url: + type: string + "400": + $ref: "#/components/responses/Problem" + "401": + $ref: "#/components/responses/Problem" + "403": + $ref: "#/components/responses/Problem" + "409": + $ref: "#/components/responses/Problem" + default: + $ref: "#/components/responses/Problem" + /api/v2/admin/invites/{inviteId}: + parameters: + - name: inviteId + in: path + required: true + schema: + type: string + delete: + security: + - browserSession: [] + operationId: revokeInvite + description: Revoke a pending invite. Used or revoked invites are 404. Admin browser session only. + parameters: + - $ref: "#/components/parameters/CsrfToken" + responses: + "204": + description: Revoked. + "401": + $ref: "#/components/responses/Problem" + "403": + $ref: "#/components/responses/Problem" + "404": + $ref: "#/components/responses/Problem" + default: + $ref: "#/components/responses/Problem" /api/v2/github/webhooks: post: operationId: receiveGitHubWebhook @@ -1250,7 +1445,47 @@ components: application/problem+json: schema: $ref: "#/components/schemas/Problem" + GoogleAuthorization: + description: Google authorization to navigate to. + content: + application/json: + schema: + type: object + additionalProperties: false + required: [authorizeUrl] + properties: + authorizeUrl: + type: string schemas: + InviteToken: + type: object + additionalProperties: false + required: [token] + properties: + token: + type: string + description: The secret from the invite link's `#t=` fragment. + Invite: + type: object + additionalProperties: false + required: [id, email, status, createdAt, expiresAt, usedBy] + properties: + id: + type: string + email: + type: string + status: + type: string + enum: [pending, used, expired, revoked] + createdAt: + type: integer + format: int64 + expiresAt: + type: integer + format: int64 + usedBy: + type: [string, "null"] + description: Username of the account the invite created; null until used or after that account is deleted. AccountUser: type: object additionalProperties: false diff --git a/packages/api/src/schema.d.ts b/packages/api/src/schema.d.ts index c90d4f29..56473bd8 100644 --- a/packages/api/src/schema.d.ts +++ b/packages/api/src/schema.d.ts @@ -56,6 +56,74 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v2/auth/google": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** @description Distributed only, when Google sign-in is configured (otherwise 404). Start Google sign-in for an existing account. Sets an HttpOnly flow cookie binding the authorization to this browser; navigate to `authorizeUrl` within 10 minutes. */ + post: operations["startGoogleSignIn"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v2/auth/google/callback": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** @description Distributed only. Google redirects the browser here; the state and the flow cookie authenticate it, so it accepts cross-site navigation. Every outcome redirects to the app; failures add `?signin=` with `cancelled`, `no-account`, `invite-mismatch`, `invite-invalid` or `failed`. A Google account is identified by its subject, so a changed email keeps the account. */ + get: operations["completeGoogleSignIn"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v2/invites/lookup": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** @description Distributed only, when Google sign-in is configured. Resolve a pending invite from its link's `#t=` secret. The secret travels in the body so it stays out of request logs. */ + post: operations["lookupInvite"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v2/invites/google": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** @description Distributed only, when Google sign-in is configured. Start Google sign-in that accepts a pending invite. The callback creates the account only when Google reports a verified email equal to the invite's; the invite is used once. */ + post: operations["startInviteGoogleSignIn"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v2/auth/logout": { parameters: { query?: never; @@ -199,6 +267,43 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v2/admin/invites": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** @description Every invite, newest first. Never exposes invite secrets. Admin browser session only. */ + get: operations["listInvites"]; + put?: never; + /** @description Invite an email to sign up with Google. Replaces the email's pending invite. The link is returned only here and expires after 48 hours. 409 when Google sign-in is not configured or an account already uses the email. Admin browser session only. */ + post: operations["createInvite"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v2/admin/invites/{inviteId}": { + parameters: { + query?: never; + header?: never; + path: { + inviteId: string; + }; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** @description Revoke a pending invite. Used or revoked invites are 404. Admin browser session only. */ + delete: operations["revokeInvite"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v2/github/webhooks": { parameters: { query?: never; @@ -699,6 +804,22 @@ export interface paths { export type webhooks = Record; export interface components { schemas: { + InviteToken: { + /** @description The secret from the invite link's `#t=` fragment. */ + token: string; + }; + Invite: { + id: string; + email: string; + /** @enum {string} */ + status: "pending" | "used" | "expired" | "revoked"; + /** Format: int64 */ + createdAt: number; + /** Format: int64 */ + expiresAt: number; + /** @description Username of the account the invite created; null until used or after that account is deleted. */ + usedBy: string | null; + }; AccountUser: { id: string; username: string; @@ -1214,6 +1335,17 @@ export interface components { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description Google authorization to navigate to. */ + GoogleAuthorization: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + authorizeUrl: string; + }; + }; + }; }; parameters: { /** @description Required for unsafe requests authenticated through a distributed browser session; attached by browser identity transport. Not applicable to local or bearer credentials. */ @@ -1341,6 +1473,106 @@ export interface operations { default: components["responses"]["Problem"]; }; }; + startGoogleSignIn: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": Record; + }; + }; + responses: { + 200: components["responses"]["GoogleAuthorization"]; + 400: components["responses"]["Problem"]; + 403: components["responses"]["Problem"]; + 404: components["responses"]["Problem"]; + 429: components["responses"]["Problem"]; + default: components["responses"]["Problem"]; + }; + }; + completeGoogleSignIn: { + parameters: { + query?: { + state?: string; + code?: string; + error?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Redirect to the app, signed in on success. */ + 303: { + headers: { + Location: string; + [name: string]: unknown; + }; + content?: never; + }; + 404: components["responses"]["Problem"]; + default: components["responses"]["Problem"]; + }; + }; + lookupInvite: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["InviteToken"]; + }; + }; + responses: { + /** @description Pending invite. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + email: string; + /** Format: int64 */ + expiresAt: number; + }; + }; + }; + 400: components["responses"]["Problem"]; + 403: components["responses"]["Problem"]; + 404: components["responses"]["Problem"]; + 429: components["responses"]["Problem"]; + default: components["responses"]["Problem"]; + }; + }; + startInviteGoogleSignIn: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["InviteToken"]; + }; + }; + responses: { + 200: components["responses"]["GoogleAuthorization"]; + 400: components["responses"]["Problem"]; + 403: components["responses"]["Problem"]; + 404: components["responses"]["Problem"]; + 429: components["responses"]["Problem"]; + default: components["responses"]["Problem"]; + }; + }; logout: { parameters: { query?: never; @@ -1602,6 +1834,95 @@ export interface operations { default: components["responses"]["Problem"]; }; }; + listInvites: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Invites. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + invites: components["schemas"]["Invite"][]; + }; + }; + }; + 401: components["responses"]["Problem"]; + 403: components["responses"]["Problem"]; + default: components["responses"]["Problem"]; + }; + }; + createInvite: { + parameters: { + query?: never; + header?: { + /** @description Required for unsafe requests authenticated through a distributed browser session; attached by browser identity transport. Not applicable to local or bearer credentials. */ + "X-CSRF-Token"?: components["parameters"]["CsrfToken"]; + }; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + email: string; + }; + }; + }; + responses: { + /** @description Created invite and its one-time link. */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + invite: components["schemas"]["Invite"]; + url: string; + }; + }; + }; + 400: components["responses"]["Problem"]; + 401: components["responses"]["Problem"]; + 403: components["responses"]["Problem"]; + 409: components["responses"]["Problem"]; + default: components["responses"]["Problem"]; + }; + }; + revokeInvite: { + parameters: { + query?: never; + header?: { + /** @description Required for unsafe requests authenticated through a distributed browser session; attached by browser identity transport. Not applicable to local or bearer credentials. */ + "X-CSRF-Token"?: components["parameters"]["CsrfToken"]; + }; + path: { + inviteId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Revoked. */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 401: components["responses"]["Problem"]; + 403: components["responses"]["Problem"]; + 404: components["responses"]["Problem"]; + default: components["responses"]["Problem"]; + }; + }; receiveGitHubWebhook: { parameters: { query?: never; diff --git a/test/conformance/server.test.ts b/test/conformance/server.test.ts index a5032065..d078e39f 100644 --- a/test/conformance/server.test.ts +++ b/test/conformance/server.test.ts @@ -400,6 +400,20 @@ test("Go distribution implements the API contract", async (t) => { assert.equal(users.response.status, 200); assert.equal(users.data?.users.length, 1); conforms("AdminUser", users.data?.users[0]); + const invites = await remote.GET("/api/v2/admin/invites", { + headers: { Cookie: cookie }, + }); + assert.equal(invites.response.status, 200); + assert.deepEqual(invites.data?.invites, []); + const inviteWithoutGoogle = await remote.POST("/api/v2/admin/invites", { + headers: accountHeaders, + body: { email: "member@example.com" }, + }); + assert.equal(inviteWithoutGoogle.response.status, 409); + const googleWithoutConfig = await remote.POST("/api/v2/auth/google", { + body: {}, + }); + assert.equal(googleWithoutConfig.response.status, 404); const missingCSRF = await remote.POST("/api/v2/account/tokens", { headers: { Cookie: cookie }, body: { name: "Denied", purpose: "api", expiresInDays: 90 }, diff --git a/tools/check-boundaries/main.go b/tools/check-boundaries/main.go index 92caf6f2..c333d46b 100644 --- a/tools/check-boundaries/main.go +++ b/tools/check-boundaries/main.go @@ -31,7 +31,9 @@ var allowed = map[string][]string{ "internal/controlapi": {"internal/config", "internal/review"}, "internal/diffsource": {"internal/processlock", "internal/review"}, // GitHub wire adapter; integration policy lives in githubsync. - "internal/github": {}, + "internal/github": {}, + // Google sign-in wire adapter; account policy lives in identityservice. + "internal/google": {}, "internal/githubsync": {"internal/fetchprotocol", "internal/github", "internal/ingestion", "internal/review"}, // The fetcher mirrors GitHub repositories and collects them offline. "internal/fetcher": {"internal/collector", "internal/diffsource", "internal/fetchprotocol", "internal/ingestion"}, @@ -42,7 +44,7 @@ var allowed = map[string][]string{ "internal/reviewservice", "internal/ingestion", "internal/processmetrics", "packages/api", }, "internal/ingestion": {"internal/review"}, - "internal/identityservice": {}, + "internal/identityservice": {"internal/google"}, "internal/ingestionqueue": {"internal/review", "internal/ingestion"}, "internal/localserver": { "internal/config", "internal/contextservice", "internal/controlapi", "internal/ingestion", "internal/processlock", @@ -52,14 +54,14 @@ var allowed = map[string][]string{ "internal/processlock": {}, "internal/processmetrics": {}, "internal/remoteserver": { - "internal/contextservice", "internal/fetchprotocol", "internal/github", "internal/githubsync", "internal/identityservice", "internal/ingestion", "internal/ingestionqueue", "internal/review", + "internal/contextservice", "internal/fetchprotocol", "internal/github", "internal/githubsync", "internal/google", "internal/identityservice", "internal/ingestion", "internal/ingestionqueue", "internal/review", "internal/reviewstore", "internal/serverapp", "internal/webui", "packages/api", }, "internal/review": {}, "internal/reviewdata": {"internal/review", "internal/ingestion"}, "internal/reviewservice": {"internal/review", "internal/reviewdata", "internal/reviewsession", "internal/contextservice", "internal/ingestion"}, "internal/reviewsession": {"internal/review"}, - "internal/reviewstore": {"internal/githubsync", "internal/identityservice", "internal/review", "internal/reviewdata", "internal/ingestion", "internal/ingestionqueue", "internal/processlock"}, + "internal/reviewstore": {"internal/githubsync", "internal/google", "internal/identityservice", "internal/review", "internal/reviewdata", "internal/ingestion", "internal/ingestionqueue", "internal/processlock"}, "internal/serverapp": { "internal/contextservice", "internal/httpapi", "internal/ingestion", "internal/ingestionqueue", "internal/mcpapi", "internal/review", "internal/reviewdata", "internal/reviewservice", "internal/version",