package api import ( "context" "crypto/rand" "encoding/hex" "errors" "fmt" "io" "log" "net/http" "time" "github.com/gin-gonic/gin" "github.com/gotify/server/v2/auth" "github.com/gotify/server/v2/config" "github.com/gotify/server/v2/database" "github.com/gotify/server/v2/decaymap" "github.com/gotify/server/v2/model" "github.com/zitadel/oidc/v3/pkg/client/rp" httphelper "github.com/zitadel/oidc/v3/pkg/http" "github.com/zitadel/oidc/v3/pkg/oidc" ) func NewOIDC(conf *config.Configuration, db *database.GormDatabase, userChangeNotifier *UserChangeNotifier) *OIDCAPI { scopes := conf.OIDC.Scopes if len(scopes) == 0 { scopes = []string{"openid", "profile", "email"} } cookieKey := make([]byte, 32) if _, err := rand.Read(cookieKey); err != nil { log.Fatalf("failed to generate OIDC cookie key: %v", err) } cookieHandlerOpt := []httphelper.CookieHandlerOpt{} if !conf.Server.SecureCookie { cookieHandlerOpt = append(cookieHandlerOpt, httphelper.WithUnsecure()) } cookieHandler := httphelper.NewCookieHandler(cookieKey, cookieKey, cookieHandlerOpt...) opts := []rp.Option{rp.WithCookieHandler(cookieHandler), rp.WithPKCE(cookieHandler)} provider, err := rp.NewRelyingPartyOIDC( context.Background(), conf.OIDC.Issuer, conf.OIDC.ClientID, conf.OIDC.ClientSecret, conf.OIDC.RedirectURL, scopes, opts..., ) if err != nil { log.Fatalf("failed to initialize OIDC provider: %v", err) } return &OIDCAPI{ DB: db, Provider: provider, UserChangeNotifier: userChangeNotifier, UsernameClaim: conf.OIDC.UsernameClaim, PasswordStrength: conf.PassStrength, SecureCookie: conf.Server.SecureCookie, AutoRegister: conf.OIDC.AutoRegister, pendingSessions: decaymap.NewDecayMap[string, *pendingOIDCSession](time.Now(), pendingSessionMaxAge), } } const pendingSessionMaxAge = 10 * time.Minute type pendingOIDCSession struct { RedirectURI string ClientName string CreatedAt time.Time Elevate *pendingElevation } type pendingElevation struct { ClientID uint `form:"id" binding:"required"` DurationSeconds int `form:"durationSeconds" binding:"required"` } // OIDCAPI provides handlers for OIDC authentication. type OIDCAPI struct { DB *database.GormDatabase Provider rp.RelyingParty UserChangeNotifier *UserChangeNotifier UsernameClaim string PasswordStrength int SecureCookie bool AutoRegister bool pendingSessions *decaymap.DecayMap[string, *pendingOIDCSession] } // swagger:operation GET /auth/oidc/login oidc oidcLogin // // Start the OIDC login flow (browser). // // Redirects the user to the OIDC provider's authorization endpoint. // After authentication, the provider redirects back to the callback endpoint. // // --- // parameters: // - name: name // in: query // description: the client name to create after login // required: true // type: string // responses: // 302: // description: Redirect to OIDC provider // default: // description: Error // schema: // $ref: "#/definitions/Error" func (a *OIDCAPI) LoginHandler() gin.HandlerFunc { return gin.WrapF(func(w http.ResponseWriter, r *http.Request) { clientName := r.URL.Query().Get("name") if clientName == "" { http.Error(w, "invalid client name", http.StatusBadRequest) return } state, err := a.generateState() if err != nil { http.Error(w, fmt.Sprintf("failed to generate state: %v", err), http.StatusInternalServerError) return } a.pendingSessions.Set(time.Now(), state, &pendingOIDCSession{ClientName: clientName, CreatedAt: time.Now()}) rp.AuthURLHandler(func() string { return state }, a.Provider)(w, r) }) } // swagger:operation GET /auth/oidc/elevate oidc oidcElevate // // Start the OIDC flow to elevate an existing client session (browser). // // Redirects the user to the OIDC provider's authorization endpoint. After // successful authentication, the referenced client session is elevated for // the requested duration. // // --- // parameters: // - name: id // in: query // description: the client id to elevate // required: true // type: integer // format: int64 // - name: durationSeconds // in: query // description: how long the elevation should last, in seconds // required: true // type: integer // responses: // 302: // description: Redirect to OIDC provider // default: // description: Error // schema: // $ref: "#/definitions/Error" func (a *OIDCAPI) ElevateHandler(ctx *gin.Context) { var elevate pendingElevation if err := ctx.BindQuery(&elevate); err != nil { return } state, err := a.generateState() if err != nil { ctx.AbortWithError(http.StatusInternalServerError, err) return } a.pendingSessions.Set(time.Now(), state, &pendingOIDCSession{CreatedAt: time.Now(), Elevate: &elevate}) rp.AuthURLHandler(func() string { return state }, a.Provider)(ctx.Writer, ctx.Request) } // swagger:operation GET /auth/oidc/callback oidc oidcCallback // // Handle the OIDC provider callback (browser). // // Exchanges the authorization code for tokens, resolves the user, // creates a gotify client, sets a session cookie, and redirects to the UI. // // --- // parameters: // - name: code // in: query // description: the authorization code from the OIDC provider // required: true // type: string // - name: state // in: query // description: the state parameter for CSRF protection // required: true // type: string // responses: // 200: // description: ok // 307: // description: Redirect to UI // default: // description: Error // schema: // $ref: "#/definitions/Error" func (a *OIDCAPI) CallbackHandler() gin.HandlerFunc { callback := func(w http.ResponseWriter, r *http.Request, tokens *oidc.Tokens[*oidc.IDTokenClaims], state string, provider rp.RelyingParty, info *oidc.UserInfo) { user, status, err := a.resolveUser(info) if err != nil { http.Error(w, err.Error(), status) return } session, ok := a.popPendingSession(state) if !ok { http.Error(w, "unknown or expired state", http.StatusBadRequest) return } if session.Elevate != nil { a.handleElevationCallback(w, session.Elevate, user) return } client, err := a.createClient(session.ClientName, user.ID) if err != nil { http.Error(w, fmt.Sprintf("failed to create client: %v", err), http.StatusInternalServerError) return } auth.SetCookie(w, client.Token, auth.CookieMaxAge, a.SecureCookie) // A reverse proxy may have already stripped a url prefix from the URL // without us knowing, we have to make a relative redirect. // We cannot use http.Redirect as this normalizes the Path with r.URL. w.Header().Set("Location", "../../") w.WriteHeader(http.StatusTemporaryRedirect) } return gin.WrapF(rp.CodeExchangeHandler(rp.UserinfoCallback(callback), a.Provider)) } func (a *OIDCAPI) handleElevationCallback(w http.ResponseWriter, elevate *pendingElevation, user *model.User) { client, err := a.DB.GetClientByID(elevate.ClientID) if err != nil { http.Error(w, fmt.Sprintf("database error: %v", err), http.StatusInternalServerError) return } if client == nil || client.UserID != user.ID { http.Error(w, "client not found", http.StatusNotFound) return } elevatedUntil := time.Now().Add(time.Duration(elevate.DurationSeconds) * time.Second) if err := a.DB.UpdateClientElevatedUntil(client.ID, &elevatedUntil); err != nil { http.Error(w, fmt.Sprintf("failed to elevate session: %v", err), http.StatusInternalServerError) return } // The UI rechecks the authentication when the tab is closed. w.WriteHeader(http.StatusOK) w.Header().Add("content-type", "text/html") io.WriteString(w, `