An OIDC-protected index page for your homeserver.
1package auth
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "log/slog"
9 "net/http"
10 "time"
11
12 badger "github.com/dgraph-io/badger/v4"
13 gonanoid "github.com/matoous/go-nanoid/v2"
14 "github.com/zitadel/logging"
15 "github.com/zitadel/oidc/v3/pkg/client/rp"
16 httphelper "github.com/zitadel/oidc/v3/pkg/http"
17 "github.com/zitadel/oidc/v3/pkg/oidc"
18)
19
20const SESSION_NAME = "_ladon_session"
21
22var (
23 ErrNoSession = errors.New("ladon: no session cookie set")
24 ErrSessionExpired = errors.New("ladon: session expired")
25)
26
27func State() string {
28 return gonanoid.Must()
29}
30
31type AuthManager struct {
32 Db *badger.DB
33 Env *EnvConfig
34 CookieHandler *httphelper.CookieHandler
35 RelyingParty rp.RelyingParty
36 Log *slog.Logger
37 HttpClient *http.Client
38}
39
40func NewAuthManager(logger *slog.Logger) *AuthManager {
41 env := EnvMustParse()
42
43 db, err := badger.Open(badger.DefaultOptions("").WithInMemory(true))
44 if err != nil {
45 panic(err)
46 }
47
48 cookieHandler := httphelper.NewCookieHandler(env.SessionSecret, env.SessionSecret, httphelper.WithUnsecure())
49
50 client := &http.Client{
51 Timeout: time.Minute,
52 }
53
54 options := []rp.Option{
55 rp.WithCookieHandler(cookieHandler),
56 rp.WithVerifierOpts(rp.WithIssuedAtOffset(5 * time.Second)),
57 rp.WithHTTPClient(client),
58 rp.WithLogger(logger),
59 rp.WithSigningAlgsFromDiscovery(),
60 }
61
62 logging.EnableHTTPClient(client,
63 logging.WithClientGroup("client"),
64 )
65
66 ctx := logging.ToContext(context.TODO(), logger)
67 provider, err := rp.NewRelyingPartyOIDC(
68 ctx,
69 env.Issuer,
70 env.ClientID,
71 env.ClientSecret,
72 fmt.Sprintf("%s/callback", env.LadonHost),
73 []string{"openid profile"},
74 options...,
75 )
76 if err != nil {
77 logger.Error("ladon: failed to instantiate relying party client")
78 panic(err)
79 }
80
81 return &AuthManager{
82 Db: db,
83 Env: env,
84 CookieHandler: cookieHandler,
85 RelyingParty: provider,
86 Log: logger,
87 HttpClient: client,
88 }
89}
90
91func (a *AuthManager) HandleLogin() http.Handler {
92 return rp.AuthURLHandler(
93 State,
94 a.RelyingParty,
95 )
96}
97
98func (a *AuthManager) HandleCallback() http.Handler {
99 return rp.CodeExchangeHandler(
100 func(
101 w http.ResponseWriter,
102 r *http.Request,
103 tokens *oidc.Tokens[*oidc.IDTokenClaims],
104 state string,
105 rp rp.RelyingParty,
106 ) {
107 data, err := json.Marshal(tokens)
108 if err != nil {
109 http.Error(w, err.Error(), http.StatusInternalServerError)
110 return
111 }
112
113 id := tokens.IDTokenClaims.Subject
114
115 if err := a.Db.Update(func(txn *badger.Txn) error {
116 e := badger.NewEntry([]byte(id), data).WithTTL(time.Duration(tokens.ExpiresIn) * time.Second)
117 err := txn.SetEntry(e)
118 return err
119 }); err != nil {
120 http.Error(w, "failed to cache user session", http.StatusInternalServerError)
121 }
122
123 if err := a.CookieHandler.SetCookie(w, SESSION_NAME, id); err != nil {
124 http.Error(w, "failed to set session cookie", http.StatusInternalServerError)
125 return
126 }
127
128 w.Header().Add("Location", "/")
129 w.WriteHeader(http.StatusFound)
130 },
131 a.RelyingParty,
132 )
133}
134
135func (a *AuthManager) HandleLogout() http.Handler {
136 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
137 a.CookieHandler.DeleteCookie(w, SESSION_NAME)
138
139 w.Header().Add("Location", "/")
140 w.WriteHeader(http.StatusFound)
141 })
142}
143
144func (a *AuthManager) GetSession(r *http.Request) (*oidc.IDTokenClaims, error) {
145 id, err := a.CookieHandler.CheckCookie(r, SESSION_NAME)
146 if errors.Is(err, http.ErrNoCookie) {
147 return nil, ErrNoSession
148 } else if err != nil {
149 return nil, err
150 }
151
152 tokens := &oidc.Tokens[*oidc.IDTokenClaims]{}
153 if err := a.Db.View(func(txn *badger.Txn) error {
154 item, err := txn.Get([]byte(id))
155 if err != nil {
156 return err
157 }
158
159 err = item.Value(func(val []byte) error {
160 return json.Unmarshal(val, tokens)
161 })
162 if err != nil {
163 return err
164 }
165
166 return nil
167 }); err != nil {
168 return nil, ErrNoSession
169 }
170
171 claims, err := rp.VerifyTokens[*oidc.IDTokenClaims](
172 context.TODO(),
173 tokens.AccessToken,
174 tokens.IDToken,
175 a.RelyingParty.IDTokenVerifier(),
176 )
177 if errors.Is(err, oidc.ErrExpired) {
178 return nil, ErrSessionExpired
179 } else if err != nil {
180 return nil, err
181 }
182
183 return claims, nil
184}
185
186func (a *AuthManager) DeleteSession(w http.ResponseWriter) {
187 a.CookieHandler.DeleteCookie(w, SESSION_NAME)
188}