From 4edf54f58f49fa77711d43007ad62b117c83c3e5 Mon Sep 17 00:00:00 2001 From: Bo-Yi Wu Date: Tue, 3 Mar 2026 13:43:36 +0800 Subject: [PATCH 1/3] feat: add and standardize OAuth provider support - Introduce full GitLab OAuth support, including provider implementation, configuration, bootstrap wiring, and login UI icon - Refactor Gitea user info fetching to use a precomputed API URL instead of deriving it from the auth endpoint - Add a shared apiURL field to OAuth providers for cleaner and more consistent user info requests - Add comprehensive test coverage for GitHub, Gitea, GitLab, and Microsoft OAuth providers, including success paths and error cases - Improve provider constructors to normalize base URLs by stripping trailing slashes - Extend provider dispatch logic to handle GitLab and add tests for unsupported providers and display names Signed-off-by: Bo-Yi Wu --- internal/auth/oauth_gitea.go | 5 +- internal/auth/oauth_gitea_test.go | 111 +++++++++++++++++++ internal/auth/oauth_github_test.go | 148 ++++++++++++++++++++++++++ internal/auth/oauth_gitlab.go | 42 ++++++++ internal/auth/oauth_gitlab_test.go | 124 +++++++++++++++++++++ internal/auth/oauth_microsoft_test.go | 146 +++++++++++++++++++++++++ internal/auth/oauth_provider.go | 32 +++++- internal/auth/oauth_provider_test.go | 67 ++++++++++++ internal/bootstrap/oauth.go | 24 +++++ internal/config/config.go | 16 +++ internal/templates/login_page.templ | 11 ++ 11 files changed, 721 insertions(+), 5 deletions(-) create mode 100644 internal/auth/oauth_gitea_test.go create mode 100644 internal/auth/oauth_github_test.go create mode 100644 internal/auth/oauth_gitlab.go create mode 100644 internal/auth/oauth_gitlab_test.go create mode 100644 internal/auth/oauth_microsoft_test.go create mode 100644 internal/auth/oauth_provider_test.go diff --git a/internal/auth/oauth_gitea.go b/internal/auth/oauth_gitea.go index 1c75fe6..17fe301 100644 --- a/internal/auth/oauth_gitea.go +++ b/internal/auth/oauth_gitea.go @@ -5,7 +5,6 @@ import ( "errors" "fmt" "strconv" - "strings" "golang.org/x/oauth2" ) @@ -23,11 +22,9 @@ func (p *OAuthProvider) getGiteaUserInfo( token *oauth2.Token, ) (*OAuthUserInfo, error) { client := p.config.Client(ctx, token) - apiURL := strings.TrimSuffix(p.config.Endpoint.AuthURL, "/login/oauth/authorize") + - "/api/v1/user" var user giteaUser - if err := fetchJSON(ctx, client, apiURL, &user); err != nil { + if err := fetchJSON(ctx, client, p.apiURL, &user); err != nil { return nil, fmt.Errorf("failed to get Gitea user info: %w", err) } diff --git a/internal/auth/oauth_gitea_test.go b/internal/auth/oauth_gitea_test.go new file mode 100644 index 0000000..4d9ce51 --- /dev/null +++ b/internal/auth/oauth_gitea_test.go @@ -0,0 +1,111 @@ +package auth + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewGiteaProvider(t *testing.T) { + p := NewGiteaProvider(OAuthProviderConfig{ + ClientID: "client-id", + ClientSecret: "client-secret", + RedirectURL: "https://example.com/callback", + Scopes: []string{"read:user"}, + }, "https://gitea.example.com") + + assert.Equal(t, "gitea", p.GetProvider()) + assert.Equal(t, "Gitea", p.GetDisplayName()) + assert.Equal(t, "https://gitea.example.com/login/oauth/authorize", p.config.Endpoint.AuthURL) + assert.Equal( + t, + "https://gitea.example.com/login/oauth/access_token", + p.config.Endpoint.TokenURL, + ) + assert.Equal(t, "https://gitea.example.com/api/v1/user", p.apiURL) +} + +func TestNewGiteaProvider_TrailingSlashStripped(t *testing.T) { + p := NewGiteaProvider(OAuthProviderConfig{}, "https://gitea.example.com/") + + assert.Equal(t, "https://gitea.example.com/login/oauth/authorize", p.config.Endpoint.AuthURL) + assert.Equal(t, "https://gitea.example.com/api/v1/user", p.apiURL) +} + +func TestGetGiteaUserInfo_Success(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v1/user", r.URL.Path) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(giteaUser{ + ID: 99, + Login: "giteatester", + FullName: "Gitea Tester", + Email: "gitea@example.com", + AvatarURL: "https://gitea.example.com/avatars/99", + }) + })) + defer server.Close() + + p := NewGiteaProvider(OAuthProviderConfig{ + ClientID: "id", + ClientSecret: "secret", + }, server.URL) + + info, err := p.GetUserInfo(context.Background(), newTestToken()) + + require.NoError(t, err) + assert.Equal(t, "99", info.ProviderUserID) + assert.Equal(t, "giteatester", info.Username) + assert.Equal(t, "Gitea Tester", info.FullName) + assert.Equal(t, "gitea@example.com", info.Email) + assert.Equal(t, "https://gitea.example.com/avatars/99", info.AvatarURL) +} + +func TestGetGiteaUserInfo_NoEmail(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(giteaUser{ID: 1, Login: "noemail", Email: ""}) + })) + defer server.Close() + + p := NewGiteaProvider(OAuthProviderConfig{}, server.URL) + info, err := p.GetUserInfo(context.Background(), newTestToken()) + + require.Error(t, err) + assert.Nil(t, info) + assert.Contains(t, err.Error(), "no email address") +} + +func TestGetGiteaUserInfo_NonOKStatus(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"message":"Unauthorized"}`)) + })) + defer server.Close() + + p := NewGiteaProvider(OAuthProviderConfig{}, server.URL) + info, err := p.GetUserInfo(context.Background(), newTestToken()) + + require.Error(t, err) + assert.Nil(t, info) + assert.Contains(t, err.Error(), "failed to get Gitea user info") +} + +func TestGetGiteaUserInfo_InvalidJSON(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte("not json")) + })) + defer server.Close() + + p := NewGiteaProvider(OAuthProviderConfig{}, server.URL) + info, err := p.GetUserInfo(context.Background(), newTestToken()) + + require.Error(t, err) + assert.Nil(t, info) +} diff --git a/internal/auth/oauth_github_test.go b/internal/auth/oauth_github_test.go new file mode 100644 index 0000000..7590e09 --- /dev/null +++ b/internal/auth/oauth_github_test.go @@ -0,0 +1,148 @@ +package auth + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewGitHubProvider(t *testing.T) { + p := NewGitHubProvider(OAuthProviderConfig{ + ClientID: "client-id", + ClientSecret: "client-secret", + RedirectURL: "https://example.com/callback", + Scopes: []string{"user:email"}, + }) + + assert.Equal(t, "github", p.GetProvider()) + assert.Equal(t, "GitHub", p.GetDisplayName()) + assert.Equal(t, "https://github.com/login/oauth/authorize", p.config.Endpoint.AuthURL) + assert.Equal(t, "https://github.com/login/oauth/access_token", p.config.Endpoint.TokenURL) +} + +func TestGetGitHubUserInfo_Success(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/user" { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(githubUser{ + ID: 12345, + Login: "octocat", + Name: "The Octocat", + Email: "octocat@github.com", + AvatarURL: "https://github.com/avatars/octocat", + }) + return + } + http.NotFound(w, r) + }) + + p := NewGitHubProvider(OAuthProviderConfig{ClientID: "id", ClientSecret: "secret"}) + ctx := contextWithMock(handler) + + info, err := p.GetUserInfo(ctx, newTestToken()) + + require.NoError(t, err) + assert.Equal(t, "12345", info.ProviderUserID) + assert.Equal(t, "octocat", info.Username) + assert.Equal(t, "The Octocat", info.FullName) + assert.Equal(t, "octocat@github.com", info.Email) + assert.Equal(t, "https://github.com/avatars/octocat", info.AvatarURL) +} + +func TestGetGitHubUserInfo_FetchesPrimaryEmail(t *testing.T) { + // Simulates a GitHub account with no public email: /user returns empty email, + // so the provider falls back to /user/emails to find the primary verified address. + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/user": + _ = json.NewEncoder(w).Encode(githubUser{ID: 1, Login: "nomail", Email: ""}) + case "/user/emails": + _ = json.NewEncoder(w).Encode([]githubEmail{ + {Email: "secondary@example.com", Primary: false, Verified: true}, + {Email: "primary@example.com", Primary: true, Verified: true}, + }) + default: + http.NotFound(w, r) + } + }) + + p := NewGitHubProvider(OAuthProviderConfig{ClientID: "id", ClientSecret: "secret"}) + info, err := p.GetUserInfo(contextWithMock(handler), newTestToken()) + + require.NoError(t, err) + assert.Equal(t, "primary@example.com", info.Email) +} + +func TestGetGitHubUserInfo_FallsBackToFirstVerifiedEmail(t *testing.T) { + // No primary+verified email; provider should fall back to the first verified email. + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/user": + _ = json.NewEncoder(w).Encode(githubUser{ID: 1, Login: "user", Email: ""}) + case "/user/emails": + _ = json.NewEncoder(w).Encode([]githubEmail{ + {Email: "unverified@example.com", Primary: true, Verified: false}, + {Email: "fallback@example.com", Primary: false, Verified: true}, + }) + } + }) + + p := NewGitHubProvider(OAuthProviderConfig{ClientID: "id", ClientSecret: "secret"}) + info, err := p.GetUserInfo(contextWithMock(handler), newTestToken()) + + require.NoError(t, err) + assert.Equal(t, "fallback@example.com", info.Email) +} + +func TestGetGitHubUserInfo_NoVerifiedEmail(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/user": + _ = json.NewEncoder(w).Encode(githubUser{ID: 1, Login: "user", Email: ""}) + case "/user/emails": + _ = json.NewEncoder(w).Encode([]githubEmail{ + {Email: "unverified@example.com", Primary: true, Verified: false}, + }) + } + }) + + p := NewGitHubProvider(OAuthProviderConfig{ClientID: "id", ClientSecret: "secret"}) + info, err := p.GetUserInfo(contextWithMock(handler), newTestToken()) + + require.Error(t, err) + assert.Nil(t, info) + assert.Contains(t, err.Error(), "no verified email") +} + +func TestGetGitHubUserInfo_NonOKStatus(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"message":"Bad credentials"}`)) + }) + + p := NewGitHubProvider(OAuthProviderConfig{ClientID: "id", ClientSecret: "secret"}) + info, err := p.GetUserInfo(contextWithMock(handler), newTestToken()) + + require.Error(t, err) + assert.Nil(t, info) + assert.Contains(t, err.Error(), "failed to get GitHub user info") +} + +func TestGetGitHubUserInfo_InvalidJSON(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte("not json")) + }) + + p := NewGitHubProvider(OAuthProviderConfig{ClientID: "id", ClientSecret: "secret"}) + info, err := p.GetUserInfo(contextWithMock(handler), newTestToken()) + + require.Error(t, err) + assert.Nil(t, info) +} diff --git a/internal/auth/oauth_gitlab.go b/internal/auth/oauth_gitlab.go new file mode 100644 index 0000000..119b41d --- /dev/null +++ b/internal/auth/oauth_gitlab.go @@ -0,0 +1,42 @@ +package auth + +import ( + "context" + "errors" + "fmt" + "strconv" + + "golang.org/x/oauth2" +) + +type gitlabUser struct { + ID int64 `json:"id"` + Username string `json:"username"` + Name string `json:"name"` + Email string `json:"email"` + AvatarURL string `json:"avatar_url"` +} + +func (p *OAuthProvider) getGitLabUserInfo( + ctx context.Context, + token *oauth2.Token, +) (*OAuthUserInfo, error) { + client := p.config.Client(ctx, token) + + var user gitlabUser + if err := fetchJSON(ctx, client, p.apiURL, &user); err != nil { + return nil, fmt.Errorf("failed to get GitLab user info: %w", err) + } + + if user.Email == "" { + return nil, errors.New("gitlab account has no email address") + } + + return &OAuthUserInfo{ + ProviderUserID: strconv.FormatInt(user.ID, 10), + Username: user.Username, + Email: user.Email, + FullName: user.Name, + AvatarURL: user.AvatarURL, + }, nil +} diff --git a/internal/auth/oauth_gitlab_test.go b/internal/auth/oauth_gitlab_test.go new file mode 100644 index 0000000..724dcf8 --- /dev/null +++ b/internal/auth/oauth_gitlab_test.go @@ -0,0 +1,124 @@ +package auth + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewGitLabProvider_DefaultURL(t *testing.T) { + p := NewGitLabProvider(OAuthProviderConfig{ + ClientID: "client-id", + ClientSecret: "client-secret", + RedirectURL: "https://example.com/callback", + Scopes: []string{"read_user"}, + }, "") + + assert.Equal(t, "gitlab", p.GetProvider()) + assert.Equal(t, "GitLab", p.GetDisplayName()) + assert.Equal(t, "https://gitlab.com/oauth/authorize", p.config.Endpoint.AuthURL) + assert.Equal(t, "https://gitlab.com/oauth/token", p.config.Endpoint.TokenURL) + assert.Equal(t, "https://gitlab.com/api/v4/user", p.apiURL) +} + +func TestNewGitLabProvider_CustomURL(t *testing.T) { + p := NewGitLabProvider(OAuthProviderConfig{ + ClientID: "client-id", + ClientSecret: "client-secret", + }, "https://gitlab.example.com") + + assert.Equal(t, "https://gitlab.example.com/oauth/authorize", p.config.Endpoint.AuthURL) + assert.Equal(t, "https://gitlab.example.com/oauth/token", p.config.Endpoint.TokenURL) + assert.Equal(t, "https://gitlab.example.com/api/v4/user", p.apiURL) +} + +func TestNewGitLabProvider_TrailingSlashStripped(t *testing.T) { + p := NewGitLabProvider(OAuthProviderConfig{}, "https://gitlab.example.com/") + + assert.Equal(t, "https://gitlab.example.com/oauth/authorize", p.config.Endpoint.AuthURL) + assert.Equal(t, "https://gitlab.example.com/oauth/token", p.config.Endpoint.TokenURL) + assert.Equal(t, "https://gitlab.example.com/api/v4/user", p.apiURL) +} + +func TestGetGitLabUserInfo_Success(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v4/user", r.URL.Path) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(gitlabUser{ + ID: 42, + Username: "jdoe", + Name: "Jane Doe", + Email: "jane@example.com", + AvatarURL: "https://gitlab.com/uploads/avatar.png", + }) + })) + defer server.Close() + + p := NewGitLabProvider(OAuthProviderConfig{ + ClientID: "id", + ClientSecret: "secret", + }, server.URL) + + info, err := p.GetUserInfo(context.Background(), newTestToken()) + + require.NoError(t, err) + assert.Equal(t, "42", info.ProviderUserID) + assert.Equal(t, "jdoe", info.Username) + assert.Equal(t, "Jane Doe", info.FullName) + assert.Equal(t, "jane@example.com", info.Email) + assert.Equal(t, "https://gitlab.com/uploads/avatar.png", info.AvatarURL) +} + +func TestGetGitLabUserInfo_NoEmail(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(gitlabUser{ + ID: 1, + Username: "noemail", + Name: "No Email", + Email: "", // empty + }) + })) + defer server.Close() + + p := NewGitLabProvider(OAuthProviderConfig{}, server.URL) + info, err := p.GetUserInfo(context.Background(), newTestToken()) + + require.Error(t, err) + assert.Nil(t, info) + assert.Contains(t, err.Error(), "no email address") +} + +func TestGetGitLabUserInfo_NonOKStatus(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"message":"401 Unauthorized"}`)) + })) + defer server.Close() + + p := NewGitLabProvider(OAuthProviderConfig{}, server.URL) + info, err := p.GetUserInfo(context.Background(), newTestToken()) + + require.Error(t, err) + assert.Nil(t, info) + assert.Contains(t, err.Error(), "failed to get GitLab user info") +} + +func TestGetGitLabUserInfo_InvalidJSON(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte("not json")) + })) + defer server.Close() + + p := NewGitLabProvider(OAuthProviderConfig{}, server.URL) + info, err := p.GetUserInfo(context.Background(), newTestToken()) + + require.Error(t, err) + assert.Nil(t, info) +} diff --git a/internal/auth/oauth_microsoft_test.go b/internal/auth/oauth_microsoft_test.go new file mode 100644 index 0000000..2d9672e --- /dev/null +++ b/internal/auth/oauth_microsoft_test.go @@ -0,0 +1,146 @@ +package auth + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewMicrosoftProvider(t *testing.T) { + p := NewMicrosoftProvider(OAuthProviderConfig{ + ClientID: "client-id", + ClientSecret: "client-secret", + RedirectURL: "https://example.com/callback", + Scopes: []string{"openid", "profile", "email", "User.Read"}, + }, "common") + + assert.Equal(t, "microsoft", p.GetProvider()) + assert.Equal(t, "Microsoft", p.GetDisplayName()) + assert.NotEmpty(t, p.config.Endpoint.AuthURL) + assert.NotEmpty(t, p.config.Endpoint.TokenURL) +} + +func TestGetMicrosoftUserInfo_Success_WithMail(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/v1.0/me" { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(microsoftUser{ + ID: "obj-uuid-123", + UserPrincipalName: "jane.doe@corp.onmicrosoft.com", + DisplayName: "Jane Doe", + Mail: "jane.doe@corp.com", + }) + return + } + http.NotFound(w, r) + }) + + p := NewMicrosoftProvider(OAuthProviderConfig{ClientID: "id", ClientSecret: "secret"}, "common") + info, err := p.GetUserInfo(contextWithMock(handler), newTestToken()) + + require.NoError(t, err) + assert.Equal(t, "obj-uuid-123", info.ProviderUserID) + assert.Equal(t, "jane.doe", info.Username) // split on @ + assert.Equal(t, "Jane Doe", info.FullName) + assert.Equal(t, "jane.doe@corp.com", info.Email) // mail preferred over UPN +} + +func TestGetMicrosoftUserInfo_Success_FallsBackToUPN(t *testing.T) { + // When mail is empty, userPrincipalName is used as the email address. + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/v1.0/me" { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(microsoftUser{ + ID: "obj-uuid-456", + UserPrincipalName: "john@example.com", + DisplayName: "John Smith", + Mail: "", // empty + }) + return + } + http.NotFound(w, r) + }) + + p := NewMicrosoftProvider( + OAuthProviderConfig{ClientID: "id", ClientSecret: "secret"}, + "tenantid", + ) + info, err := p.GetUserInfo(contextWithMock(handler), newTestToken()) + + require.NoError(t, err) + assert.Equal(t, "john@example.com", info.Email) + assert.Equal(t, "john", info.Username) +} + +func TestGetMicrosoftUserInfo_FullNameFromNameParts(t *testing.T) { + // When DisplayName is empty, full name is assembled from given + surname. + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/v1.0/me" { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(microsoftUser{ + ID: "uuid", + UserPrincipalName: "alice@example.com", + DisplayName: "", + GivenName: "Alice", + Surname: "Wonder", + }) + return + } + http.NotFound(w, r) + }) + + p := NewMicrosoftProvider(OAuthProviderConfig{ClientID: "id", ClientSecret: "secret"}, "common") + info, err := p.GetUserInfo(contextWithMock(handler), newTestToken()) + + require.NoError(t, err) + assert.Equal(t, "Alice Wonder", info.FullName) +} + +func TestGetMicrosoftUserInfo_NoEmail(t *testing.T) { + // Both mail and userPrincipalName are empty → error. + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/v1.0/me" { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(microsoftUser{ID: "uuid"}) + return + } + http.NotFound(w, r) + }) + + p := NewMicrosoftProvider(OAuthProviderConfig{ClientID: "id", ClientSecret: "secret"}, "common") + info, err := p.GetUserInfo(contextWithMock(handler), newTestToken()) + + require.Error(t, err) + assert.Nil(t, info) + assert.Contains(t, err.Error(), "no email address") +} + +func TestGetMicrosoftUserInfo_NonOKStatus(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":{"code":"InvalidAuthenticationToken"}}`)) + }) + + p := NewMicrosoftProvider(OAuthProviderConfig{ClientID: "id", ClientSecret: "secret"}, "common") + info, err := p.GetUserInfo(contextWithMock(handler), newTestToken()) + + require.Error(t, err) + assert.Nil(t, info) + assert.Contains(t, err.Error(), "failed to get Microsoft user info") +} + +func TestGetMicrosoftUserInfo_InvalidJSON(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte("not json")) + }) + + p := NewMicrosoftProvider(OAuthProviderConfig{ClientID: "id", ClientSecret: "secret"}, "common") + info, err := p.GetUserInfo(contextWithMock(handler), newTestToken()) + + require.Error(t, err) + assert.Nil(t, info) +} diff --git a/internal/auth/oauth_provider.go b/internal/auth/oauth_provider.go index 7e6ecfb..36e34f8 100644 --- a/internal/auth/oauth_provider.go +++ b/internal/auth/oauth_provider.go @@ -33,7 +33,8 @@ type OAuthUserInfo struct { // OAuthProvider handles OAuth authentication type OAuthProvider struct { config *oauth2.Config - provider string // "github", "gitea", "microsoft", etc. + provider string // "github", "gitea", "gitlab", "microsoft", etc. + apiURL string // Pre-computed user info endpoint (set for instance-based providers) } // NewGitHubProvider creates a new GitHub OAuth provider @@ -52,8 +53,10 @@ func NewGitHubProvider(cfg OAuthProviderConfig) *OAuthProvider { // NewGiteaProvider creates a new Gitea OAuth provider func NewGiteaProvider(cfg OAuthProviderConfig, giteaURL string) *OAuthProvider { + giteaURL = strings.TrimSuffix(giteaURL, "/") return &OAuthProvider{ provider: "gitea", + apiURL: giteaURL + "/api/v1/user", config: &oauth2.Config{ ClientID: cfg.ClientID, ClientSecret: cfg.ClientSecret, @@ -81,6 +84,31 @@ func NewMicrosoftProvider(cfg OAuthProviderConfig, tenantID string) *OAuthProvid } } +// NewGitLabProvider creates a new GitLab OAuth provider. +// gitlabURL should be the base URL of the GitLab instance (e.g. "https://gitlab.com" +// or "https://gitlab.example.com" for self-hosted). It defaults to "https://gitlab.com" +// when empty. +func NewGitLabProvider(cfg OAuthProviderConfig, gitlabURL string) *OAuthProvider { + if gitlabURL == "" { + gitlabURL = "https://gitlab.com" + } + gitlabURL = strings.TrimSuffix(gitlabURL, "/") + return &OAuthProvider{ + provider: "gitlab", + apiURL: gitlabURL + "/api/v4/user", + config: &oauth2.Config{ + ClientID: cfg.ClientID, + ClientSecret: cfg.ClientSecret, + RedirectURL: cfg.RedirectURL, + Scopes: cfg.Scopes, + Endpoint: oauth2.Endpoint{ + AuthURL: gitlabURL + "/oauth/authorize", + TokenURL: gitlabURL + "/oauth/token", + }, + }, + } +} + // GetAuthURL returns the OAuth authorization URL func (p *OAuthProvider) GetAuthURL(state string) string { return p.config.AuthCodeURL(state, oauth2.AccessTypeOffline) @@ -101,6 +129,8 @@ func (p *OAuthProvider) GetUserInfo( return p.getGitHubUserInfo(ctx, token) case "gitea": return p.getGiteaUserInfo(ctx, token) + case "gitlab": + return p.getGitLabUserInfo(ctx, token) case "microsoft": return p.getMicrosoftUserInfo(ctx, token) default: diff --git a/internal/auth/oauth_provider_test.go b/internal/auth/oauth_provider_test.go new file mode 100644 index 0000000..9634444 --- /dev/null +++ b/internal/auth/oauth_provider_test.go @@ -0,0 +1,67 @@ +package auth + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "golang.org/x/oauth2" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// newTestToken returns a minimal oauth2.Token shared across provider tests. +func newTestToken() *oauth2.Token { + return &oauth2.Token{AccessToken: "test-access-token"} +} + +// mockTransport is an http.RoundTripper that dispatches to an http.Handler. +// Inject it via contextWithMock to intercept calls from p.config.Client(ctx, token). +type mockTransport struct { + handler http.Handler +} + +func (m *mockTransport) RoundTrip(req *http.Request) (*http.Response, error) { + w := httptest.NewRecorder() + m.handler.ServeHTTP(w, req) + return w.Result(), nil +} + +// contextWithMock returns a context that injects handler as the HTTP transport +// used by oauth2 clients, enabling tests of providers with hardcoded API URLs. +func contextWithMock(handler http.Handler) context.Context { + return context.WithValue( + context.Background(), + oauth2.HTTPClient, + &http.Client{Transport: &mockTransport{handler: handler}}, + ) +} + +func TestGetDisplayName(t *testing.T) { + tests := []struct { + provider string + want string + }{ + {"github", "GitHub"}, + {"gitea", "Gitea"}, + {"gitlab", "GitLab"}, + {"microsoft", "Microsoft"}, + {"custom", "Custom"}, + {"", ""}, + } + for _, tt := range tests { + p := &OAuthProvider{provider: tt.provider} + assert.Equal(t, tt.want, p.GetDisplayName(), "provider=%q", tt.provider) + } +} + +func TestGetUserInfo_UnsupportedProvider(t *testing.T) { + p := &OAuthProvider{provider: "unsupported"} + info, err := p.GetUserInfo(context.Background(), newTestToken()) + + require.Error(t, err) + assert.Nil(t, info) + assert.Contains(t, err.Error(), "unsupported provider") +} diff --git a/internal/bootstrap/oauth.go b/internal/bootstrap/oauth.go index 5cd4d88..fd6e65d 100644 --- a/internal/bootstrap/oauth.go +++ b/internal/bootstrap/oauth.go @@ -71,6 +71,30 @@ func initializeOAuthProviders(cfg *config.Config) map[string]*auth.OAuthProvider ) } + // GitLab OAuth + switch { + case !cfg.GitLabOAuthEnabled: + // Skip GitLab OAuth + case cfg.GitLabClientID == "" || cfg.GitLabClientSecret == "": + log.Printf("Warning: GitLab OAuth enabled but CLIENT_ID or CLIENT_SECRET missing") + default: + gitlabURL := cfg.GitLabURL + if gitlabURL == "" { + gitlabURL = "https://gitlab.com" + } + providers["gitlab"] = auth.NewGitLabProvider(auth.OAuthProviderConfig{ + ClientID: cfg.GitLabClientID, + ClientSecret: cfg.GitLabClientSecret, + RedirectURL: cfg.GitLabOAuthRedirectURL, + Scopes: cfg.GitLabOAuthScopes, + }, gitlabURL) + log.Printf( + "GitLab OAuth configured: server=%s redirect=%s", + gitlabURL, + cfg.GitLabOAuthRedirectURL, + ) + } + return providers } diff --git a/internal/config/config.go b/internal/config/config.go index 689a8ee..af88a45 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -137,6 +137,14 @@ type Config struct { MicrosoftOAuthRedirectURL string MicrosoftOAuthScopes []string + // GitLab OAuth + GitLabOAuthEnabled bool + GitLabURL string // Base URL; defaults to "https://gitlab.com" for cloud + GitLabClientID string + GitLabClientSecret string + GitLabOAuthRedirectURL string + GitLabOAuthScopes []string + // OAuth Auto Registration OAuthAutoRegister bool // Allow OAuth to auto-create accounts (default: true) @@ -304,6 +312,14 @@ func Load() *Config { []string{"openid", "profile", "email", "User.Read"}, ), + // GitLab OAuth + GitLabOAuthEnabled: getEnvBool("GITLAB_OAUTH_ENABLED", false), + GitLabURL: getEnv("GITLAB_URL", ""), + GitLabClientID: getEnv("GITLAB_CLIENT_ID", ""), + GitLabClientSecret: getEnv("GITLAB_CLIENT_SECRET", ""), + GitLabOAuthRedirectURL: getEnv("GITLAB_REDIRECT_URL", ""), + GitLabOAuthScopes: getEnvSlice("GITLAB_SCOPES", []string{"read_user"}), + // OAuth Auto Registration OAuthAutoRegister: getEnvBool("OAUTH_AUTO_REGISTER", true), diff --git a/internal/templates/login_page.templ b/internal/templates/login_page.templ index 5391f27..bb9fe0d 100644 --- a/internal/templates/login_page.templ +++ b/internal/templates/login_page.templ @@ -45,6 +45,17 @@ templ LoginPage(props LoginPageProps) { } + if provider.Name == "gitlab" { + + } Sign in with { provider.DisplayName } } From 373f91d877f98da8f6390db4f7ee477291eaf35a Mon Sep 17 00:00:00 2001 From: Bo-Yi Wu Date: Tue, 3 Mar 2026 13:58:57 +0800 Subject: [PATCH 2/3] Update internal/bootstrap/oauth.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- internal/bootstrap/oauth.go | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/internal/bootstrap/oauth.go b/internal/bootstrap/oauth.go index fd6e65d..5930051 100644 --- a/internal/bootstrap/oauth.go +++ b/internal/bootstrap/oauth.go @@ -78,19 +78,15 @@ func initializeOAuthProviders(cfg *config.Config) map[string]*auth.OAuthProvider case cfg.GitLabClientID == "" || cfg.GitLabClientSecret == "": log.Printf("Warning: GitLab OAuth enabled but CLIENT_ID or CLIENT_SECRET missing") default: - gitlabURL := cfg.GitLabURL - if gitlabURL == "" { - gitlabURL = "https://gitlab.com" - } providers["gitlab"] = auth.NewGitLabProvider(auth.OAuthProviderConfig{ ClientID: cfg.GitLabClientID, ClientSecret: cfg.GitLabClientSecret, RedirectURL: cfg.GitLabOAuthRedirectURL, Scopes: cfg.GitLabOAuthScopes, - }, gitlabURL) + }, cfg.GitLabURL) log.Printf( "GitLab OAuth configured: server=%s redirect=%s", - gitlabURL, + cfg.GitLabURL, cfg.GitLabOAuthRedirectURL, ) } From a1dd41d27e3a1933ced6726f5eb14fdff7e0b6bc Mon Sep 17 00:00:00 2001 From: Bo-Yi Wu Date: Tue, 3 Mar 2026 13:59:13 +0800 Subject: [PATCH 3/3] style: style GitLab OAuth login button - Add styling for a GitLab OAuth login button, including colors, hover effects, and SVG icon sizing Signed-off-by: Bo-Yi Wu --- internal/templates/static/css/pages/login.css | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/internal/templates/static/css/pages/login.css b/internal/templates/static/css/pages/login.css index 2b318e3..9af45b1 100644 --- a/internal/templates/static/css/pages/login.css +++ b/internal/templates/static/css/pages/login.css @@ -209,6 +209,24 @@ color: white; } +/* GitLab button */ +.login-oauth-btn.gitlab { + background: #fc6d26; + color: white; + box-shadow: 0 4px 12px rgba(252, 109, 38, 0.3); +} + +.login-oauth-btn.gitlab svg { + width: 24px; + height: 24px; +} + +.login-oauth-btn.gitlab:hover { + background: #e85c1a; + box-shadow: 0 6px 20px rgba(252, 109, 38, 0.4); + color: white; +} + /* Divider */ .login-divider { position: relative;