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..5930051 100644
--- a/internal/bootstrap/oauth.go
+++ b/internal/bootstrap/oauth.go
@@ -71,6 +71,26 @@ 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:
+ providers["gitlab"] = auth.NewGitLabProvider(auth.OAuthProviderConfig{
+ ClientID: cfg.GitLabClientID,
+ ClientSecret: cfg.GitLabClientSecret,
+ RedirectURL: cfg.GitLabOAuthRedirectURL,
+ Scopes: cfg.GitLabOAuthScopes,
+ }, cfg.GitLabURL)
+ log.Printf(
+ "GitLab OAuth configured: server=%s redirect=%s",
+ cfg.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 }
}
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;