blob: ea7029a38247bb13f77af6eae8eb3d5a837ba35d [file]
/*
Licensed to the Apache Software Foundation (ASF) under one or more
contributor license agreements. See the NOTICE file distributed with
this work for additional information regarding copyright ownership.
The ASF licenses this file to You under the Apache License, Version 2.0
(the "License"); you may not use this file except in compliance with
the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
// Package auth implements user-facing OIDC login for DevLake. Supports any
// number of OIDC providers configured via OIDC_PROVIDERS + per-provider env
// vars; routes /auth/login?provider=<name> to the corresponding IdP.
package auth
import (
stdctx "context"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"golang.org/x/oauth2"
corectx "github.com/apache/incubator-devlake/core/context"
"github.com/apache/incubator-devlake/core/dal"
"github.com/apache/incubator-devlake/core/errors"
"github.com/apache/incubator-devlake/core/log"
"github.com/apache/incubator-devlake/helpers/oidchelper"
"github.com/apache/incubator-devlake/server/api/shared"
)
// Auth-related route paths, defined in one place so router registration and
// the middleware whitelist cannot drift.
const (
PathMethods = "/auth/methods"
PathLogin = "/auth/login"
PathCallback = "/auth/callback"
PathLogout = "/auth/logout"
PathUserInfo = "/auth/userinfo"
)
// lastSeenThrottle bounds DB writes to one per-jti per window. Tracking
// "last activity" doesn't need per-request precision.
const lastSeenThrottle = 5 * time.Minute
// Service holds everything the auth handlers and middlewares need. It is the
// unit of testability for this package: tests build one with stub deps and
// drive the gin handlers directly.
type Service struct {
cfg *oidchelper.Config
providers map[string]*oidchelper.Provider
logger log.Logger
db dal.Dal
revoked *revocationCache
lastSeenMu sync.Mutex
lastSeen map[string]time.Time
}
// defaultService is populated by Init and backs the package-level handler /
// middleware wrappers. Tests that need isolation should construct their own
// *Service via NewService.
var (
defaultService *Service
initOnce sync.Once
)
// Init builds the default Service from env config and starts the background
// loops. Panics if auth is enabled but the config is incomplete.
func Init(basicRes corectx.BasicRes) {
initOnce.Do(func() {
s, err := NewService(stdctx.Background(), basicRes)
if err != nil {
panic(fmt.Errorf("auth init: %w", err))
}
defaultService = s
})
}
// NewService is the testable constructor. ctx governs the lifetime of the
// revocation refresher and session cleanup goroutines.
func NewService(ctx stdctx.Context, basicRes corectx.BasicRes) (*Service, error) {
cfg, err := oidchelper.LoadConfig(basicRes)
if err != nil {
return nil, err
}
s := &Service{
cfg: cfg,
providers: map[string]*oidchelper.Provider{},
logger: basicRes.GetLogger(),
db: basicRes.GetDal(),
revoked: newRevocationCache(),
lastSeen: map[string]time.Time{},
}
if cfg.AuthEnabled {
startRefresher(ctx, s.revoked, s.db, s.logger)
startSessionCleanup(ctx, s.db, s.logger)
}
if cfg.OIDCEnabled {
for name, pc := range cfg.Providers {
s.providers[name] = oidchelper.NewProvider(pc)
s.logger.Info("OIDC provider %q enabled (issuer=%s, client=%s)", name, pc.IssuerURL, pc.ClientID)
}
} else if cfg.AuthEnabled {
s.logger.Info("AUTH_ENABLED but OIDC_ENABLED=false: only API-key/proxy auth will work")
}
return s, nil
}
func (s *Service) Config() *oidchelper.Config { return s.cfg }
// Config returns the default service's config. Nil before Init has run.
func Config() *oidchelper.Config {
if defaultService == nil {
return nil
}
return defaultService.cfg
}
type ProviderInfo struct {
Name string `json:"name"`
DisplayName string `json:"displayName"`
LoginURL string `json:"loginUrl"`
}
type Methods struct {
Providers []ProviderInfo `json:"providers,omitempty"`
APIKey *APIKey `json:"apiKey,omitempty"`
}
type APIKey struct {
Enabled bool `json:"enabled"`
}
func GetMethods(c *gin.Context) { defaultService.GetMethods(c) }
// @Summary List enabled login methods
// @Tags framework/auth
// @Success 200 {object} Methods
// @Router /auth/methods [get]
func (s *Service) GetMethods(c *gin.Context) {
out := Methods{APIKey: &APIKey{Enabled: true}}
if s.cfg != nil && s.cfg.OIDCEnabled {
for _, name := range s.cfg.ProviderNames() {
pc := s.cfg.Providers[name]
out.Providers = append(out.Providers, ProviderInfo{
Name: name,
DisplayName: pc.DisplayName,
LoginURL: PathLogin + "?provider=" + url.QueryEscape(name),
})
}
}
shared.ApiOutputSuccess(c, out, http.StatusOK)
}
func LoginInit(c *gin.Context) { defaultService.LoginInit(c) }
// @Summary Begin OIDC login
// @Tags framework/auth
// @Param provider query string false "provider name (required when more than one is configured)"
// @Param return_url query string false "where to redirect after login"
// @Success 303
// @Router /auth/login [get]
func (s *Service) LoginInit(c *gin.Context) {
if !s.ensureOIDC(c) {
return
}
name, p, ok := s.pickProvider(c, c.Query("provider"))
if !ok {
return
}
returnURL := safeReturnURL(c.Query("return_url"))
verifier, err := newPKCEVerifier()
if err != nil {
fail(c, http.StatusInternalServerError, "pkce generate", err)
return
}
nonce, err := oidchelper.NewNonce()
if err != nil {
fail(c, http.StatusInternalServerError, "state nonce", err)
return
}
encoded, err := oidchelper.EncodeState(s.cfg.SessionSecret, &oidchelper.StatePayload{
Provider: name,
Nonce: nonce,
ReturnURL: returnURL,
PKCEVerifier: verifier,
IssuedAt: time.Now(),
})
if err != nil {
fail(c, http.StatusInternalServerError, "state encode", err)
return
}
oidchelper.SetStateCookie(c, s.cfg, encoded)
oa, err := p.OAuth2Config(c.Request.Context())
if err != nil {
fail(c, http.StatusBadGateway, "oauth2 config", err)
return
}
authURL := oa.AuthCodeURL(nonce,
oauth2.SetAuthURLParam("code_challenge", pkceChallenge(verifier)),
oauth2.SetAuthURLParam("code_challenge_method", "S256"),
)
c.Redirect(http.StatusSeeOther, authURL)
}
func Callback(c *gin.Context) { defaultService.Callback(c) }
// @Summary OIDC callback
// @Tags framework/auth
// @Success 303
// @Router /auth/callback [get]
func (s *Service) Callback(c *gin.Context) {
if !s.ensureOIDC(c) {
return
}
encoded, err := c.Cookie(oidchelper.StateCookieName)
if err != nil || encoded == "" {
fail(c, http.StatusBadRequest, "missing state cookie", err)
return
}
oidchelper.ClearStateCookie(c, s.cfg)
state, err := oidchelper.DecodeState(s.cfg.SessionSecret, encoded)
if err != nil {
fail(c, http.StatusBadRequest, "state decode", err)
return
}
// Constant-time compare even though the nonce is non-secret. Keeps the
// auth path free of timing-leak audit questions.
if subtle.ConstantTimeCompare([]byte(c.Query("state")), []byte(state.Nonce)) != 1 {
fail(c, http.StatusBadRequest, "state mismatch", nil)
return
}
p, ok := s.providers[state.Provider]
if !ok {
fail(c, http.StatusBadRequest, "unknown provider in state: "+state.Provider, nil)
return
}
code := c.Query("code")
if code == "" {
if msg := c.Query("error"); msg != "" {
fail(c, http.StatusBadRequest, "idp error: "+msg+" "+c.Query("error_description"), nil)
return
}
fail(c, http.StatusBadRequest, "missing authorization code", nil)
return
}
oa, err := p.OAuth2Config(c.Request.Context())
if err != nil {
fail(c, http.StatusBadGateway, "oauth2 config", err)
return
}
exchangeOpts := []oauth2.AuthCodeOption{
oauth2.SetAuthURLParam("code_verifier", state.PKCEVerifier),
}
if pc := s.cfg.Providers[state.Provider]; pc != nil && pc.UseWorkloadIdentity {
assertion, werr := oidchelper.FederatedAssertion()
if werr != nil {
fail(c, http.StatusInternalServerError, "workload identity assertion", werr)
return
}
exchangeOpts = append(exchangeOpts,
oauth2.SetAuthURLParam("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"),
oauth2.SetAuthURLParam("client_assertion", assertion),
)
}
tok, err := oa.Exchange(c.Request.Context(), code, exchangeOpts...)
if err != nil {
fail(c, http.StatusBadGateway, "code exchange", err)
return
}
rawID, _ := tok.Extra("id_token").(string)
if rawID == "" {
fail(c, http.StatusBadGateway, "id_token missing from token response", nil)
return
}
idTok, err := p.VerifyIDToken(c.Request.Context(), rawID)
if err != nil {
fail(c, http.StatusUnauthorized, "id_token verify", err)
return
}
sub, email, name, err := extractUser(idTok)
if err != nil {
fail(c, http.StatusBadGateway, "extract claims", err)
return
}
jti := uuid.NewString()
jwt, expiresAt, err := oidchelper.IssueSession(s.cfg, jti, state.Provider, sub, email, name)
if err != nil {
fail(c, http.StatusInternalServerError, "issue session", err)
return
}
now := time.Now()
if dbErr := CreateSession(s.db, &AuthSession{
Jti: jti,
Sub: sub,
Email: email,
Name: name,
IssuedAt: now,
ExpiresAt: expiresAt,
LastSeenAt: now,
}); dbErr != nil {
fail(c, http.StatusInternalServerError, "persist session", dbErr)
return
}
csrf, err := oidchelper.NewCSRFToken()
if err != nil {
fail(c, http.StatusInternalServerError, "csrf token", err)
return
}
oidchelper.SetSessionCookie(c, s.cfg, jwt)
oidchelper.SetCSRFCookie(c, s.cfg, csrf)
s.logger.Info("oidc login: provider=%s sub=%s email=%s jti=%s", state.Provider, sub, email, jti)
c.Redirect(http.StatusSeeOther, state.ReturnURL)
}
type logoutResponse struct {
OK bool `json:"ok"`
LogoutURL string `json:"logoutUrl,omitempty"`
}
func Logout(c *gin.Context) { defaultService.Logout(c) }
// @Summary Logout
// @Tags framework/auth
// @Success 200 {object} logoutResponse
// @Router /auth/logout [post]
func (s *Service) Logout(c *gin.Context) {
if s.cfg == nil || !s.cfg.AuthEnabled {
shared.ApiOutputSuccess(c, logoutResponse{OK: true}, http.StatusOK)
return
}
var sessionProvider string
if len(s.cfg.SessionSecret) > 0 {
if raw, err := c.Cookie(oidchelper.SessionCookieName); err == nil && raw != "" {
if claims, err := oidchelper.ParseSession(s.cfg.SessionSecret, raw); err == nil && claims.ID != "" {
sessionProvider = claims.Provider
if err := RevokeSession(s.db, claims.ID); err != nil {
s.logger.Error(err, "auth: revoke session row")
}
s.revoked.Add(claims.ID)
}
}
}
oidchelper.ClearSessionCookie(c, s.cfg)
oidchelper.ClearCSRFCookie(c, s.cfg)
out := logoutResponse{OK: true}
if s.cfg.OIDCEnabled && s.cfg.LogoutRedirect && sessionProvider != "" {
if p, ok := s.providers[sessionProvider]; ok {
if u, err := p.EndSessionURL(c.Request.Context()); err == nil && u != "" {
out.LogoutURL = u
}
}
}
shared.ApiOutputSuccess(c, out, http.StatusOK)
}
type userInfoResponse struct {
Authenticated bool `json:"authenticated"`
Name string `json:"name"`
Email string `json:"email"`
}
func UserInfo(c *gin.Context) { defaultService.UserInfo(c) }
// UserInfo always returns 200 so calling it does not trigger the UI's
// 401-redirect loop; the `authenticated` field discriminates.
//
// @Summary Current user
// @Tags framework/auth
// @Success 200 {object} userInfoResponse
// @Router /auth/userinfo [get]
func (s *Service) UserInfo(c *gin.Context) {
u, ok := shared.GetUser(c)
if !ok || u == nil {
shared.ApiOutputSuccess(c, userInfoResponse{Authenticated: false}, http.StatusOK)
return
}
shared.ApiOutputSuccess(c, userInfoResponse{
Authenticated: true,
Name: u.Name,
Email: u.Email,
}, http.StatusOK)
}
// pickProvider resolves the requested provider name. Empty names are allowed
// only when exactly one provider is configured (single-IdP convenience).
func (s *Service) pickProvider(c *gin.Context, requested string) (string, *oidchelper.Provider, bool) {
name := strings.ToLower(strings.TrimSpace(requested))
if name == "" {
if len(s.providers) == 1 {
for n := range s.providers {
name = n
}
} else {
fail(c, http.StatusBadRequest, "provider query param required (multiple configured)", nil)
return "", nil, false
}
}
p, ok := s.providers[name]
if !ok {
fail(c, http.StatusBadRequest, "unknown provider: "+name, nil)
return "", nil, false
}
return name, p, true
}
func (s *Service) ensureOIDC(c *gin.Context) bool {
if s.cfg == nil || !s.cfg.OIDCEnabled || len(s.providers) == 0 {
shared.ApiOutputError(c, errors.HttpStatus(http.StatusServiceUnavailable).New("OIDC is not enabled"))
return false
}
return true
}
// bumpLastSeen records that jti was used. The DB write happens at most once
// per lastSeenThrottle per jti, off the request path.
func (s *Service) bumpLastSeen(jti string) {
now := time.Now()
s.lastSeenMu.Lock()
if last, ok := s.lastSeen[jti]; ok && now.Sub(last) < lastSeenThrottle {
s.lastSeenMu.Unlock()
return
}
s.lastSeen[jti] = now
s.lastSeenMu.Unlock()
go func() {
if err := UpdateLastSeen(s.db, jti, now); err != nil {
s.logger.Debug("auth: bump last_seen jti=%s: %v", jti, err)
}
}()
}
func fail(c *gin.Context, status int, msg string, cause error) {
wrapped := errors.HttpStatus(status).New(msg)
if cause != nil {
wrapped = errors.HttpStatus(status).Wrap(cause, msg)
}
shared.ApiOutputError(c, wrapped)
}
// safeReturnURL prevents open-redirect by stripping anything that doesn't
// look like a same-origin path. The // and /\ prefixes get a fast path
// because some browsers normalize backslash → slash and would treat
// /\evil.com as a protocol-relative URL.
func safeReturnURL(raw string) string {
if raw == "" {
return "/"
}
if strings.HasPrefix(raw, "//") || strings.HasPrefix(raw, `/\`) {
return "/"
}
u, err := url.Parse(raw)
if err != nil {
return "/"
}
if u.IsAbs() || u.Host != "" || !strings.HasPrefix(u.Path, "/") {
return "/"
}
return u.RequestURI()
}
// extractUser pulls user identity out of the verified ID token. Email is
// taken strictly from the `email` claim; we never coerce a username into
// the email column. Display name falls back to preferred_username, then
// email, so the UI always has *something* to render.
func extractUser(tok *oidc.IDToken) (sub, email, name string, err error) {
var claims struct {
Sub string `json:"sub"`
Email string `json:"email"`
PreferredUsername string `json:"preferred_username"`
Name string `json:"name"`
}
if err := tok.Claims(&claims); err != nil {
return "", "", "", err
}
if claims.Sub == "" {
return "", "", "", fmt.Errorf("id_token missing sub claim")
}
name = claims.Name
if name == "" {
name = claims.PreferredUsername
}
if name == "" {
name = claims.Email
}
return claims.Sub, claims.Email, name, nil
}
func pkceChallenge(verifier string) string {
sum := sha256.Sum256([]byte(verifier))
return base64.RawURLEncoding.EncodeToString(sum[:])
}
// newPKCEVerifier returns a 64-char random URL-safe string per RFC 7636.
func newPKCEVerifier() (string, error) {
b := make([]byte, 48)
if _, err := io.ReadFull(rand.Reader, b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}