package migration import ( "context" "encoding/json" "errors" "fmt" "net/url" "strings" "time" "github.com/redis/go-redis/v9" "golang.org/x/oauth2" ) const pendingKeyPrefix = "migration_oauth_pending:" const pendingTTL = 15 * time.Minute var ErrUnknownState = errors.New("migration oauth state expired or unknown") var ErrProviderDisabled = errors.New("migration oauth provider not configured") type Provider string const ( ProviderGoogle Provider = "google" ProviderMicrosoft Provider = "microsoft" ) type PendingOAuth struct { UserID string `json:"user_id"` ProjectID string `json:"project_id"` Provider string `json:"provider"` InviteToken string `json:"invite_token,omitempty"` PKCEVerifier string `json:"pkce_verifier"` } type OAuthConfig struct { GoogleClientID string GoogleClientSecret string MicrosoftClientID string MicrosoftSecret string MicrosoftTenant string RedirectURL string } type OAuthService struct { cfg OAuthConfig rdb *redis.Client } func NewOAuthService(cfg OAuthConfig, rdb *redis.Client) *OAuthService { return &OAuthService{cfg: cfg, rdb: rdb} } func (s *OAuthService) EnabledProviders() []string { var out []string if s.providerConfig(ProviderGoogle) != nil { out = append(out, string(ProviderGoogle)) } if s.providerConfig(ProviderMicrosoft) != nil { out = append(out, string(ProviderMicrosoft)) } return out } func (s *OAuthService) Start(ctx context.Context, pending PendingOAuth, provider Provider) (authURL, state string, err error) { oauthCfg := s.providerConfig(provider) if oauthCfg == nil { return "", "", ErrProviderDisabled } verifier, challenge, err := newPKCE() if err != nil { return "", "", err } state, err = randomState() if err != nil { return "", "", err } pending.Provider = string(provider) pending.PKCEVerifier = verifier if err := s.savePending(ctx, state, pending); err != nil { return "", "", err } authURL = oauthCfg.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.SetAuthURLParam("code_challenge", challenge), oauth2.SetAuthURLParam("code_challenge_method", "S256"), oauth2.SetAuthURLParam("prompt", "consent"), ) return authURL, state, nil } func (s *OAuthService) Exchange(ctx context.Context, state, code string) (PendingOAuth, *oauth2.Token, []string, error) { pending, err := s.loadPending(ctx, state) if err != nil { return PendingOAuth{}, nil, nil, err } oauthCfg := s.providerConfig(Provider(pending.Provider)) if oauthCfg == nil { return PendingOAuth{}, nil, nil, ErrProviderDisabled } token, err := oauthCfg.Exchange(ctx, code, oauth2.SetAuthURLParam("code_verifier", pending.PKCEVerifier)) if err != nil { return PendingOAuth{}, nil, nil, fmt.Errorf("token exchange: %w", err) } _ = s.rdb.Del(ctx, pendingKeyPrefix+state).Err() return pending, token, oauthCfg.Scopes, nil } func (s *OAuthService) Refresh(ctx context.Context, provider Provider, refreshToken string) (*oauth2.Token, error) { oauthCfg := s.providerConfig(provider) if oauthCfg == nil { return nil, ErrProviderDisabled } if strings.TrimSpace(refreshToken) == "" { return nil, fmt.Errorf("refresh token required") } token, err := oauthCfg.TokenSource(ctx, &oauth2.Token{RefreshToken: refreshToken}).Token() if err != nil { return nil, fmt.Errorf("token refresh: %w", err) } return token, nil } // AdminConsentURL returns the Microsoft tenant admin consent URL for migration scopes. func (s *OAuthService) AdminConsentURL(tenant, state string) (string, error) { if s.cfg.MicrosoftClientID == "" || s.cfg.RedirectURL == "" { return "", ErrProviderDisabled } tenant = strings.TrimSpace(tenant) if tenant == "" { tenant = s.cfg.MicrosoftTenant } if tenant == "" { tenant = "common" } values := url.Values{} values.Set("client_id", s.cfg.MicrosoftClientID) values.Set("redirect_uri", s.cfg.RedirectURL) if state = strings.TrimSpace(state); state != "" { values.Set("state", state) } return fmt.Sprintf("https://login.microsoftonline.com/%s/adminconsent?%s", url.PathEscape(tenant), values.Encode()), nil } func (s *OAuthService) MicrosoftClientID() string { return s.cfg.MicrosoftClientID } func (s *OAuthService) providerConfig(provider Provider) *oauth2.Config { switch provider { case ProviderGoogle: if s.cfg.GoogleClientID == "" || s.cfg.GoogleClientSecret == "" || s.cfg.RedirectURL == "" { return nil } return &oauth2.Config{ ClientID: s.cfg.GoogleClientID, ClientSecret: s.cfg.GoogleClientSecret, RedirectURL: s.cfg.RedirectURL, Scopes: []string{ "https://www.googleapis.com/auth/gmail.readonly", "https://www.googleapis.com/auth/drive.readonly", "https://www.googleapis.com/auth/calendar.readonly", "https://www.googleapis.com/auth/contacts.readonly", }, Endpoint: oauth2.Endpoint{ AuthURL: "https://accounts.google.com/o/oauth2/v2/auth", TokenURL: "https://oauth2.googleapis.com/token", }, } case ProviderMicrosoft: if s.cfg.MicrosoftClientID == "" || s.cfg.MicrosoftSecret == "" || s.cfg.RedirectURL == "" { return nil } tenant := s.cfg.MicrosoftTenant if tenant == "" { tenant = "common" } return &oauth2.Config{ ClientID: s.cfg.MicrosoftClientID, ClientSecret: s.cfg.MicrosoftSecret, RedirectURL: s.cfg.RedirectURL, Scopes: []string{ "offline_access", "User.Read", "Mail.Read", "Files.Read.All", "Calendars.Read", "Contacts.Read", }, Endpoint: oauth2.Endpoint{ AuthURL: fmt.Sprintf("https://login.microsoftonline.com/%s/oauth2/v2.0/authorize", tenant), TokenURL: fmt.Sprintf("https://login.microsoftonline.com/%s/oauth2/v2.0/token", tenant), }, } default: return nil } } func (s *OAuthService) savePending(ctx context.Context, state string, pending PendingOAuth) error { if s.rdb == nil { return errors.New("oauth state store unavailable") } raw, err := json.Marshal(pending) if err != nil { return err } return s.rdb.Set(ctx, pendingKeyPrefix+state, raw, pendingTTL).Err() } func (s *OAuthService) loadPending(ctx context.Context, state string) (PendingOAuth, error) { if s.rdb == nil { return PendingOAuth{}, errors.New("oauth state store unavailable") } raw, err := s.rdb.Get(ctx, pendingKeyPrefix+state).Bytes() if err != nil { if errors.Is(err, redis.Nil) { return PendingOAuth{}, ErrUnknownState } return PendingOAuth{}, err } var pending PendingOAuth if err := json.Unmarshal(raw, &pending); err != nil { return PendingOAuth{}, err } return pending, nil }