Skip to content

Commit 37c4f58

Browse files
wesmclaude
andcommitted
Accept Workspace aliases with warning instead of hard reject
Google Workspace accounts may return a primary address from the profile API that differs from the alias the user added. Since we can't verify Workspace aliases without admin API access, accept same-domain mismatches with a logged warning. Gmail/googlemail domains are excluded from this relaxation since their aliases are fully handled by normalizeGmailAddress. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent baced31 commit 37c4f58

File tree

2 files changed

+141
-10
lines changed

2 files changed

+141
-10
lines changed

internal/oauth/oauth.go

Lines changed: 39 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -323,17 +323,27 @@ func (m *Manager) resolveTokenEmail(
323323
"(re-run the command to try again)", email, err)
324324
}
325325

326-
// Accept the token if the profile email matches the expected
327-
// email (case-insensitive) or shares the same Google domain
328-
// (alias). Reject if the emails are for entirely different
329-
// accounts.
330326
if !sameGoogleAccount(email, profile.EmailAddress) {
331-
return "", fmt.Errorf(
332-
"token mismatch: expected %s but authorized as %s "+
333-
"(wrong account selected during authorization — "+
334-
"please try again and select %s)",
335-
email, profile.EmailAddress, email,
336-
)
327+
// For Workspace domains, the profile may return the
328+
// primary address when the user added an alias. We
329+
// can't verify aliases without admin API access, so
330+
// accept same-domain mismatches with a warning.
331+
if sameWorkspaceDomain(email, profile.EmailAddress) {
332+
m.logger.Warn(
333+
"profile email differs from expected "+
334+
"(possible Workspace alias)",
335+
"expected", email,
336+
"profile", profile.EmailAddress,
337+
)
338+
} else {
339+
return "", fmt.Errorf(
340+
"token mismatch: expected %s but authorized "+
341+
"as %s (wrong account selected during "+
342+
"authorization — please try again and "+
343+
"select %s)",
344+
email, profile.EmailAddress, email,
345+
)
346+
}
337347
}
338348

339349
return profile.EmailAddress, nil
@@ -359,6 +369,25 @@ func sameGoogleAccount(expected, canonical string) bool {
359369
return expectedNorm != "" && expectedNorm == canonicalNorm
360370
}
361371

372+
// sameWorkspaceDomain returns true if two email addresses share the
373+
// same non-Gmail domain (case-insensitive). Returns false for
374+
// gmail.com and googlemail.com since those aliases are fully handled
375+
// by sameGoogleAccount/normalizeGmailAddress.
376+
func sameWorkspaceDomain(a, b string) bool {
377+
ai := strings.LastIndex(a, "@")
378+
bi := strings.LastIndex(b, "@")
379+
if ai < 0 || bi < 0 {
380+
return false
381+
}
382+
domA := strings.ToLower(a[ai+1:])
383+
domB := strings.ToLower(b[bi+1:])
384+
if domA != domB {
385+
return false
386+
}
387+
// Gmail aliases are handled by sameGoogleAccount
388+
return domA != "gmail.com" && domA != "googlemail.com"
389+
}
390+
362391
// normalizeGmailAddress returns a canonical form of a gmail.com or
363392
// googlemail.com address by lowercasing, removing dots from the local
364393
// part, and mapping googlemail.com → gmail.com. Returns "" for

internal/oauth/oauth_test.go

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"crypto/sha256"
66
"encoding/json"
77
"fmt"
8+
"log/slog"
89
"net/http"
910
"net/http/httptest"
1011
"os"
@@ -27,6 +28,7 @@ func setupTestManager(t *testing.T, scopes []string) *Manager {
2728
return &Manager{
2829
config: &oauth2.Config{Scopes: scopes},
2930
tokensDir: tokensDir,
31+
logger: slog.Default(),
3032
}
3133
}
3234

@@ -677,6 +679,106 @@ func TestAuthorize_RejectsMismatch(t *testing.T) {
677679
}
678680
}
679681

682+
// TestAuthorize_WorkspaceAlias verifies that a Workspace alias
683+
// (different local part, same domain) is accepted with a warning
684+
// rather than hard-rejected.
685+
func TestAuthorize_WorkspaceAlias(t *testing.T) {
686+
srv := httptest.NewServer(http.HandlerFunc(
687+
func(w http.ResponseWriter, r *http.Request) {
688+
w.Header().Set("Content-Type", "application/json")
689+
_, _ = fmt.Fprintf(w,
690+
`{"emailAddress": "primary@company.com"}`)
691+
}))
692+
defer srv.Close()
693+
694+
mgr := setupTestManager(t, Scopes)
695+
mgr.profileURL = srv.URL
696+
mgr.browserFlowFn = func(
697+
_ context.Context, _ string, _ bool,
698+
) (*oauth2.Token, error) {
699+
return &oauth2.Token{
700+
AccessToken: "ws-token",
701+
TokenType: "Bearer",
702+
Expiry: time.Now().Add(time.Hour),
703+
}, nil
704+
}
705+
706+
// alias@company.com -> profile returns primary@company.com
707+
err := mgr.Authorize(context.Background(), "alias@company.com")
708+
if err != nil {
709+
t.Fatalf("Authorize should accept same-domain alias: %v", err)
710+
}
711+
712+
// Token saved under the original alias identifier.
713+
loaded, err := mgr.loadToken("alias@company.com")
714+
if err != nil {
715+
t.Fatalf("loadToken failed: %v", err)
716+
}
717+
if loaded.AccessToken != "ws-token" {
718+
t.Errorf("wrong access token: got %q", loaded.AccessToken)
719+
}
720+
}
721+
722+
// TestAuthorize_CrossDomainReject verifies that entirely different
723+
// domains are rejected even for Workspace accounts.
724+
func TestAuthorize_CrossDomainReject(t *testing.T) {
725+
srv := httptest.NewServer(http.HandlerFunc(
726+
func(w http.ResponseWriter, r *http.Request) {
727+
w.Header().Set("Content-Type", "application/json")
728+
_, _ = fmt.Fprintf(w,
729+
`{"emailAddress": "user@other.com"}`)
730+
}))
731+
defer srv.Close()
732+
733+
mgr := setupTestManager(t, Scopes)
734+
mgr.profileURL = srv.URL
735+
mgr.browserFlowFn = func(
736+
_ context.Context, _ string, _ bool,
737+
) (*oauth2.Token, error) {
738+
return &oauth2.Token{
739+
AccessToken: "test",
740+
TokenType: "Bearer",
741+
Expiry: time.Now().Add(time.Hour),
742+
}, nil
743+
}
744+
745+
err := mgr.Authorize(context.Background(), "user@company.com")
746+
if err == nil {
747+
t.Fatal("expected error for cross-domain mismatch")
748+
}
749+
if !strings.Contains(err.Error(), "token mismatch") {
750+
t.Errorf("error should contain 'token mismatch': %q",
751+
err.Error())
752+
}
753+
}
754+
755+
func TestSameWorkspaceDomain(t *testing.T) {
756+
t.Parallel()
757+
tests := []struct {
758+
a, b string
759+
want bool
760+
}{
761+
{"user@company.com", "admin@company.com", true},
762+
{"user@Company.Com", "admin@company.com", true},
763+
{"user@company.com", "user@other.com", false},
764+
{"user@gmail.com", "other@gmail.com", false},
765+
{"user@googlemail.com", "other@googlemail.com", false},
766+
{"noat", "user@gmail.com", false},
767+
{"user@gmail.com", "noat", false},
768+
}
769+
for _, tt := range tests {
770+
t.Run(tt.a+"_"+tt.b, func(t *testing.T) {
771+
t.Parallel()
772+
got := sameWorkspaceDomain(tt.a, tt.b)
773+
if got != tt.want {
774+
t.Errorf(
775+
"sameWorkspaceDomain(%q, %q) = %v, want %v",
776+
tt.a, tt.b, got, tt.want)
777+
}
778+
})
779+
}
780+
}
781+
680782
func TestSameGoogleAccount(t *testing.T) {
681783
t.Parallel()
682784

0 commit comments

Comments
 (0)