Skip to content

Commit 81699f7

Browse files
authored
Merge pull request #941 from gotify/oidc
OIDC Server
2 parents 1434380 + 2674f72 commit 81699f7

34 files changed

+1958
-233
lines changed

api/health.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ type HealthAPI struct {
1616
}
1717

1818
// Health returns health information.
19-
// swagger:operation GET /health health getHealth
19+
// swagger:operation GET /health info getHealth
2020
//
2121
// Get health information.
2222
//

api/oidc.go

Lines changed: 349 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,349 @@
1+
package api
2+
3+
import (
4+
"context"
5+
"crypto/rand"
6+
"encoding/hex"
7+
"errors"
8+
"fmt"
9+
"log"
10+
"net/http"
11+
"time"
12+
13+
"github.com/gin-gonic/gin"
14+
"github.com/gotify/server/v2/auth"
15+
"github.com/gotify/server/v2/config"
16+
"github.com/gotify/server/v2/database"
17+
"github.com/gotify/server/v2/decaymap"
18+
"github.com/gotify/server/v2/model"
19+
"github.com/zitadel/oidc/v3/pkg/client/rp"
20+
httphelper "github.com/zitadel/oidc/v3/pkg/http"
21+
"github.com/zitadel/oidc/v3/pkg/oidc"
22+
)
23+
24+
func NewOIDC(conf *config.Configuration, db *database.GormDatabase, userChangeNotifier *UserChangeNotifier) *OIDCAPI {
25+
scopes := conf.OIDC.Scopes
26+
if len(scopes) == 0 {
27+
scopes = []string{"openid", "profile", "email"}
28+
}
29+
30+
cookieKey := make([]byte, 32)
31+
if _, err := rand.Read(cookieKey); err != nil {
32+
log.Fatalf("failed to generate OIDC cookie key: %v", err)
33+
}
34+
cookieHandlerOpt := []httphelper.CookieHandlerOpt{}
35+
if !conf.Server.SecureCookie {
36+
cookieHandlerOpt = append(cookieHandlerOpt, httphelper.WithUnsecure())
37+
}
38+
cookieHandler := httphelper.NewCookieHandler(cookieKey, cookieKey, cookieHandlerOpt...)
39+
40+
opts := []rp.Option{rp.WithCookieHandler(cookieHandler), rp.WithPKCE(cookieHandler)}
41+
42+
provider, err := rp.NewRelyingPartyOIDC(
43+
context.Background(),
44+
conf.OIDC.Issuer,
45+
conf.OIDC.ClientID,
46+
conf.OIDC.ClientSecret,
47+
conf.OIDC.RedirectURL,
48+
scopes,
49+
opts...,
50+
)
51+
if err != nil {
52+
log.Fatalf("failed to initialize OIDC provider: %v", err)
53+
}
54+
55+
return &OIDCAPI{
56+
DB: db,
57+
Provider: provider,
58+
UserChangeNotifier: userChangeNotifier,
59+
UsernameClaim: conf.OIDC.UsernameClaim,
60+
PasswordStrength: conf.PassStrength,
61+
SecureCookie: conf.Server.SecureCookie,
62+
AutoRegister: conf.OIDC.AutoRegister,
63+
pendingSessions: decaymap.NewDecayMap[string, *pendingOIDCSession](time.Now(), pendingSessionMaxAge),
64+
}
65+
}
66+
67+
const pendingSessionMaxAge = 10 * time.Minute
68+
69+
type pendingOIDCSession struct {
70+
RedirectURI string
71+
ClientName string
72+
CreatedAt time.Time
73+
}
74+
75+
// OIDCAPI provides handlers for OIDC authentication.
76+
type OIDCAPI struct {
77+
DB *database.GormDatabase
78+
Provider rp.RelyingParty
79+
UserChangeNotifier *UserChangeNotifier
80+
UsernameClaim string
81+
PasswordStrength int
82+
SecureCookie bool
83+
AutoRegister bool
84+
pendingSessions *decaymap.DecayMap[string, *pendingOIDCSession]
85+
}
86+
87+
// swagger:operation GET /auth/oidc/login oidc oidcLogin
88+
//
89+
// Start the OIDC login flow (browser).
90+
//
91+
// Redirects the user to the OIDC provider's authorization endpoint.
92+
// After authentication, the provider redirects back to the callback endpoint.
93+
//
94+
// ---
95+
// parameters:
96+
// - name: name
97+
// in: query
98+
// description: the client name to create after login
99+
// required: true
100+
// type: string
101+
// responses:
102+
// 302:
103+
// description: Redirect to OIDC provider
104+
// default:
105+
// description: Error
106+
// schema:
107+
// $ref: "#/definitions/Error"
108+
func (a *OIDCAPI) LoginHandler() gin.HandlerFunc {
109+
return gin.WrapF(func(w http.ResponseWriter, r *http.Request) {
110+
clientName := r.URL.Query().Get("name")
111+
if clientName == "" {
112+
http.Error(w, "invalid client name", http.StatusBadRequest)
113+
return
114+
}
115+
state, err := a.generateState()
116+
if err != nil {
117+
http.Error(w, fmt.Sprintf("failed to generate state: %v", err), http.StatusInternalServerError)
118+
return
119+
}
120+
a.pendingSessions.Set(time.Now(), state, &pendingOIDCSession{ClientName: clientName, CreatedAt: time.Now()})
121+
rp.AuthURLHandler(func() string { return state }, a.Provider)(w, r)
122+
})
123+
}
124+
125+
// swagger:operation GET /auth/oidc/callback oidc oidcCallback
126+
//
127+
// Handle the OIDC provider callback (browser).
128+
//
129+
// Exchanges the authorization code for tokens, resolves the user,
130+
// creates a gotify client, sets a session cookie, and redirects to the UI.
131+
//
132+
// ---
133+
// parameters:
134+
// - name: code
135+
// in: query
136+
// description: the authorization code from the OIDC provider
137+
// required: true
138+
// type: string
139+
// - name: state
140+
// in: query
141+
// description: the state parameter for CSRF protection
142+
// required: true
143+
// type: string
144+
// responses:
145+
// 307:
146+
// description: Redirect to UI
147+
// default:
148+
// description: Error
149+
// schema:
150+
// $ref: "#/definitions/Error"
151+
func (a *OIDCAPI) CallbackHandler() gin.HandlerFunc {
152+
callback := func(w http.ResponseWriter, r *http.Request, tokens *oidc.Tokens[*oidc.IDTokenClaims], state string, provider rp.RelyingParty, info *oidc.UserInfo) {
153+
user, status, err := a.resolveUser(info)
154+
if err != nil {
155+
http.Error(w, err.Error(), status)
156+
return
157+
}
158+
session, ok := a.popPendingSession(state)
159+
if !ok {
160+
http.Error(w, "unknown or expired state", http.StatusBadRequest)
161+
return
162+
}
163+
client, err := a.createClient(session.ClientName, user.ID)
164+
if err != nil {
165+
http.Error(w, fmt.Sprintf("failed to create client: %v", err), http.StatusInternalServerError)
166+
return
167+
}
168+
auth.SetCookie(w, client.Token, auth.CookieMaxAge, a.SecureCookie)
169+
// A reverse proxy may have already stripped a url prefix from the URL
170+
// without us knowing, we have to make a relative redirect.
171+
// We cannot use http.Redirect as this normalizes the Path with r.URL.
172+
w.Header().Set("Location", "../../")
173+
w.WriteHeader(http.StatusTemporaryRedirect)
174+
}
175+
return gin.WrapF(rp.CodeExchangeHandler(rp.UserinfoCallback(callback), a.Provider))
176+
}
177+
178+
// swagger:operation POST /auth/oidc/external/authorize oidc externalAuthorize
179+
//
180+
// Initiate the OIDC authorization flow for a native app.
181+
//
182+
// The app generates a PKCE code_verifier and code_challenge, then calls this
183+
// endpoint. The server forwards the code_challenge to the OIDC provider and
184+
// returns the authorization URL for the app to open in a browser.
185+
//
186+
// ---
187+
// consumes: [application/json]
188+
// produces: [application/json]
189+
// parameters:
190+
// - name: body
191+
// in: body
192+
// required: true
193+
// schema:
194+
// $ref: "#/definitions/OIDCExternalAuthorizeRequest"
195+
// responses:
196+
// 200:
197+
// description: Ok
198+
// schema:
199+
// $ref: "#/definitions/OIDCExternalAuthorizeResponse"
200+
// default:
201+
// description: Error
202+
// schema:
203+
// $ref: "#/definitions/Error"
204+
func (a *OIDCAPI) ExternalAuthorizeHandler(ctx *gin.Context) {
205+
var req model.OIDCExternalAuthorizeRequest
206+
if err := ctx.ShouldBindJSON(&req); err != nil {
207+
ctx.AbortWithError(http.StatusBadRequest, err)
208+
return
209+
}
210+
state, err := a.generateState()
211+
if err != nil {
212+
ctx.AbortWithError(http.StatusInternalServerError, err)
213+
return
214+
}
215+
a.pendingSessions.Set(time.Now(), state, &pendingOIDCSession{
216+
RedirectURI: req.RedirectURI, ClientName: req.Name, CreatedAt: time.Now(),
217+
})
218+
authOpts := []rp.AuthURLOpt{
219+
rp.AuthURLOpt(rp.WithURLParam("redirect_uri", req.RedirectURI)),
220+
rp.WithCodeChallenge(req.CodeChallenge),
221+
}
222+
ctx.JSON(http.StatusOK, &model.OIDCExternalAuthorizeResponse{
223+
AuthorizeURL: rp.AuthURL(state, a.Provider, authOpts...),
224+
State: state,
225+
})
226+
}
227+
228+
// swagger:operation POST /auth/oidc/external/token oidc externalToken
229+
//
230+
// Exchange an authorization code for a gotify client token.
231+
//
232+
// After the user authenticates with the OIDC provider and the app receives
233+
// the authorization code via redirect, the app calls this endpoint with the
234+
// code and PKCE code_verifier. The server exchanges the code with the OIDC
235+
// provider and returns a gotify client token.
236+
//
237+
// ---
238+
// consumes: [application/json]
239+
// produces: [application/json]
240+
// parameters:
241+
// - name: body
242+
// in: body
243+
// required: true
244+
// schema:
245+
// $ref: "#/definitions/OIDCExternalTokenRequest"
246+
// responses:
247+
// 200:
248+
// description: Ok
249+
// schema:
250+
// $ref: "#/definitions/OIDCExternalTokenResponse"
251+
// default:
252+
// description: Error
253+
// schema:
254+
// $ref: "#/definitions/Error"
255+
func (a *OIDCAPI) ExternalTokenHandler(ctx *gin.Context) {
256+
var req model.OIDCExternalTokenRequest
257+
if err := ctx.ShouldBindJSON(&req); err != nil {
258+
ctx.AbortWithError(http.StatusBadRequest, err)
259+
return
260+
}
261+
session, ok := a.popPendingSession(req.State)
262+
if !ok {
263+
ctx.AbortWithError(http.StatusBadRequest, errors.New("unknown or expired state"))
264+
return
265+
}
266+
exchangeOpts := []rp.CodeExchangeOpt{
267+
rp.CodeExchangeOpt(rp.WithURLParam("redirect_uri", session.RedirectURI)),
268+
rp.WithCodeVerifier(req.CodeVerifier),
269+
}
270+
tokens, err := rp.CodeExchange[*oidc.IDTokenClaims](ctx.Request.Context(), req.Code, a.Provider, exchangeOpts...)
271+
if err != nil {
272+
ctx.AbortWithError(http.StatusUnauthorized, fmt.Errorf("token exchange failed: %w", err))
273+
return
274+
}
275+
info, err := rp.Userinfo[*oidc.UserInfo](ctx.Request.Context(), tokens.AccessToken, tokens.TokenType, tokens.IDTokenClaims.GetSubject(), a.Provider)
276+
if err != nil {
277+
ctx.AbortWithError(http.StatusInternalServerError, fmt.Errorf("failed to get user info: %w", err))
278+
return
279+
}
280+
user, status, resolveErr := a.resolveUser(info)
281+
if resolveErr != nil {
282+
ctx.AbortWithError(status, resolveErr)
283+
return
284+
}
285+
client, err := a.createClient(session.ClientName, user.ID)
286+
if err != nil {
287+
ctx.AbortWithError(http.StatusInternalServerError, err)
288+
return
289+
}
290+
ctx.JSON(http.StatusOK, &model.OIDCExternalTokenResponse{
291+
Token: client.Token,
292+
User: &model.UserExternal{ID: user.ID, Name: user.Name, Admin: user.Admin},
293+
})
294+
}
295+
296+
func (a *OIDCAPI) generateState() (string, error) {
297+
nonce := make([]byte, 20)
298+
if _, err := rand.Read(nonce); err != nil {
299+
return "", err
300+
}
301+
return hex.EncodeToString(nonce), nil
302+
}
303+
304+
// resolveUser looks up or creates a user from OIDC userinfo claims.
305+
func (a *OIDCAPI) resolveUser(info *oidc.UserInfo) (*model.User, int, error) {
306+
usernameRaw, ok := info.Claims[a.UsernameClaim]
307+
if !ok {
308+
return nil, http.StatusInternalServerError, fmt.Errorf("username claim %q is missing", a.UsernameClaim)
309+
}
310+
username := fmt.Sprint(usernameRaw)
311+
if username == "" || usernameRaw == nil {
312+
return nil, http.StatusInternalServerError, fmt.Errorf("username claim was empty")
313+
}
314+
315+
user, err := a.DB.GetUserByName(username)
316+
if err != nil {
317+
return nil, http.StatusInternalServerError, fmt.Errorf("database error: %w", err)
318+
}
319+
if user == nil {
320+
if !a.AutoRegister {
321+
return nil, http.StatusForbidden, fmt.Errorf("user does not exist and auto-registration is disabled")
322+
}
323+
user = &model.User{Name: username, Admin: false, Pass: nil}
324+
if err := a.DB.CreateUser(user); err != nil {
325+
return nil, http.StatusInternalServerError, fmt.Errorf("failed to create user: %w", err)
326+
}
327+
if err := a.UserChangeNotifier.fireUserAdded(user.ID); err != nil {
328+
log.Printf("Could not notify user change: %v\n", err)
329+
}
330+
}
331+
return user, 0, nil
332+
}
333+
334+
func (a *OIDCAPI) createClient(name string, userID uint) (*model.Client, error) {
335+
client := &model.Client{
336+
Name: name,
337+
Token: auth.GenerateNotExistingToken(generateClientToken, func(t string) bool { c, _ := a.DB.GetClientByToken(t); return c != nil }),
338+
UserID: userID,
339+
}
340+
return client, a.DB.CreateClient(client)
341+
}
342+
343+
func (a *OIDCAPI) popPendingSession(key string) (*pendingOIDCSession, bool) {
344+
session, ok := a.pendingSessions.Pop(key)
345+
if ok && time.Since(session.CreatedAt) < pendingSessionMaxAge {
346+
return session, true
347+
}
348+
return nil, false
349+
}

0 commit comments

Comments
 (0)