Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions backend/api-files/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,26 @@ components:
type: string
format: date-time

InstanceConfig:
type: object
description: Public configuration of this instance, used by clients to decide which flows to offer
required:
- oidc
properties:
oidc:
type: object
description: Generic OpenID Connect login
required:
- enabled
- display_name
properties:
enabled:
type: boolean
description: True when an OpenID Connect provider is configured and reachable
display_name:
type: string
description: Label for the login button

securitySchemes:
BearerAuth:
type: http
Expand All @@ -332,6 +352,18 @@ paths:
"200":
description: Metrics in Prometheus format

/api/config:
get:
summary: Public instance configuration
description: Unauthenticated. Tells clients which login flows are available on this instance.
responses:
"200":
description: Instance configuration
content:
application/json:
schema:
$ref: "#/components/schemas/InstanceConfig"

/api/auth/social/:provider:
get:
summary: Initiate social login with specified provider
Expand Down
4 changes: 2 additions & 2 deletions backend/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ require (
github.com/alicebob/miniredis/v2 v2.38.0
github.com/getsentry/sentry-go v0.31.1
github.com/getsentry/sentry-go/echo v0.31.1
github.com/go-jose/go-jose/v3 v3.0.5
github.com/go-playground/validator v9.31.0+incompatible
github.com/golang-jwt/jwt/v5 v5.2.2
github.com/google/uuid v1.6.0
Expand All @@ -29,6 +30,7 @@ require (
github.com/twitchtv/twirp v8.1.3+incompatible
github.com/wader/gormstore/v2 v2.0.3
golang.org/x/crypto v0.46.0
golang.org/x/oauth2 v0.34.0
gorm.io/datatypes v1.2.7
gorm.io/driver/postgres v1.5.9
gorm.io/driver/sqlite v1.6.0
Expand All @@ -54,7 +56,6 @@ require (
github.com/frostbyte73/core v0.1.1 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/gammazero/deque v1.2.0 // indirect
github.com/go-jose/go-jose/v3 v3.0.5 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-playground/locales v0.14.1 // indirect
Expand Down Expand Up @@ -126,7 +127,6 @@ require (
golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39 // indirect
golang.org/x/mod v0.30.0 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/oauth2 v0.34.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect
Expand Down
28 changes: 28 additions & 0 deletions backend/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,18 @@ type Config struct {
GitHubRedirect string
CallbackURL string
SessionSecret string
// OIDC configures an optional generic OpenID Connect provider.
OIDC struct {
IssuerURL string
ClientID string
ClientSecret string // optional, public clients rely on PKCE alone
DisplayName string // label of the login button
Redirect string
// SingleTeam puts new OIDC users without an invitation into one
// shared team, created by the first of them, instead of giving
// each their own.
SingleTeam bool
}
}
Livekit struct {
APIKey string
Expand Down Expand Up @@ -145,6 +157,16 @@ func Load() (*Config, error) {
c.Auth.GitHubSecret = os.Getenv("GITHUB_SECRET")
c.Auth.GitHubRedirect = fmt.Sprintf("https://%s/api/auth/social/github/callback", c.Server.DeployDomain)

c.Auth.OIDC.IssuerURL = strings.TrimRight(os.Getenv("OIDC_ISSUER_URL"), "/")
c.Auth.OIDC.ClientID = os.Getenv("OIDC_CLIENT_ID")
c.Auth.OIDC.ClientSecret = os.Getenv("OIDC_CLIENT_SECRET")
c.Auth.OIDC.DisplayName = os.Getenv("OIDC_DISPLAY_NAME")
if c.Auth.OIDC.DisplayName == "" {
c.Auth.OIDC.DisplayName = "SSO"
}
c.Auth.OIDC.Redirect = fmt.Sprintf("https://%s/api/auth/social/oidc/callback", c.Server.DeployDomain)
c.Auth.OIDC.SingleTeam = os.Getenv("OIDC_SINGLE_TEAM") == "true"

c.Database.DSN = os.Getenv("DATABASE_DSN")
c.Database.RedisURI = os.Getenv("REDIS_URI")

Expand Down Expand Up @@ -221,3 +243,9 @@ func (c *Config) IsTurnstileEnabled() bool {
func (c *Config) IsStripeEnabled() bool {
return c.Stripe.SecretKey != ""
}

// IsOIDCConfigured reports whether the generic OpenID Connect provider is
// configured. Only the issuer and client ID are required.
func (c *Config) IsOIDCConfigured() bool {
return c.Auth.OIDC.IssuerURL != "" && c.Auth.OIDC.ClientID != ""
}
56 changes: 55 additions & 1 deletion backend/internal/handlers/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,16 @@ func (r *RealGothicProvider) CompleteUserAuth(res http.ResponseWriter, req *http
}

func (h *AuthHandler) SocialLoginCallback(c echo.Context) error {
isOIDC := c.Param("provider") == oidcProviderName
if isOIDC {
if err := addOIDCCodeVerifier(c); err != nil {
c.Logger().Warnf("OIDC login rejected: %v", err)
// Returning here skips gothic's own cleanup of its session cookie.
_ = gothic.Logout(c.Response(), c.Request())
return echo.NewHTTPError(http.StatusBadRequest, "Login session expired, please sign in again")
}
}

user, err := h.SocialAuth.CompleteUserAuth(c.Response(), c.Request())
if err != nil {
return err
Expand All @@ -102,6 +112,25 @@ func (h *AuthHandler) SocialLoginCallback(c echo.Context) error {
return echo.NewHTTPError(http.StatusBadRequest, "Email is required but not provided by the authentication provider")
}

if isOIDC {
// goth validates issuer, audience and expiry of the ID token but not
// its signature, so verify it before trusting any claim.
if err := verifyOIDCIDToken(user.IDToken); err != nil {
c.Logger().Warnf("OIDC login rejected: %v", err)
return echo.NewHTTPError(http.StatusUnauthorized, "Invalid ID token")
}

// Accounts are matched by email, so an unverified address must not be
// able to take over an existing account.
if !oidcEmailVerified(user) {
c.Logger().Warn("OIDC login rejected, email is not verified by the identity provider")
return c.Redirect(http.StatusFound, "/login?error=email_not_verified")
}
if user.FirstName == "" {
user.FirstName = oidcFallbackFirstName(user)
}
}

var u models.User
// Will be used to get Slack's team name in case its not an invite
var teamName string
Expand Down Expand Up @@ -135,6 +164,26 @@ func (h *AuthHandler) SocialLoginCallback(c echo.Context) error {
sess.Save(c.Request(), c.Response())
}

// Single-team mode: OIDC users without an invitation join the team
// marked as the OIDC team, never any other team of the instance. If
// there is none yet, the first user falls through and creates it as
// admin.
singleTeam := isOIDC && h.Config.Auth.OIDC.SingleTeam
if assignedTeamID == nil && singleTeam {
// Held until the transaction ends, so a concurrent first
// sign-in waits here and then finds the team created below.
if err := lockOIDCTeamSetup(tx); err != nil {
return fmt.Errorf("failed to lock OIDC team setup: %w", err)
}
var team models.Team
err := tx.Where("is_oidc_team = ?", true).Order("id ASC").First(&team).Error
if err == nil {
assignedTeamID = &team.ID
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return fmt.Errorf("failed to look up team: %w", err)
}
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
var isAdmin = false
// If no team invitation, we need to create a new team
if assignedTeamID == nil {
Expand Down Expand Up @@ -181,7 +230,8 @@ func (h *AuthHandler) SocialLoginCallback(c echo.Context) error {

// Create a new team
team := models.Team{
Name: teamName,
Name: teamName,
IsOIDCTeam: singleTeam,
}
if err := tx.Create(&team).Error; err != nil {
return fmt.Errorf("failed to create team: %w", err)
Expand Down Expand Up @@ -348,6 +398,10 @@ func (h *AuthHandler) SocialLogin(c echo.Context) error {
q.Set("provider", provider)
req.URL.RawQuery = q.Encode()

if provider == oidcProviderName {
return beginOIDCAuth(c)
}

gothic.BeginAuthHandler(c.Response(), req)
return nil
}
Expand Down
34 changes: 34 additions & 0 deletions backend/internal/handlers/instanceConfig.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package handlers

import (
"net/http"

"github.com/labstack/echo/v4"
"github.com/markbates/goth"
)

// OIDCConfigResponse describes the generic OpenID Connect login.
type OIDCConfigResponse struct {
Enabled bool `json:"enabled"`
DisplayName string `json:"display_name"`
}

// InstanceConfigResponse is the public, unauthenticated description of how this
// instance is configured. Clients use it to decide which flows to offer.
type InstanceConfigResponse struct {
OIDC OIDCConfigResponse `json:"oidc"`
}

// GetInstanceConfig returns the instance configuration relevant to clients.
func (h *AuthHandler) GetInstanceConfig(c echo.Context) error {
// Enabled only if the provider was actually registered: discovery can fail
// at startup even though OIDC is configured.
_, err := goth.GetProvider(oidcProviderName)

return c.JSON(http.StatusOK, InstanceConfigResponse{
OIDC: OIDCConfigResponse{
Enabled: err == nil,
DisplayName: h.Config.Auth.OIDC.DisplayName,
},
})
}
Loading
Loading