feat(master): OIDC login with group check, sessions and CSRF protection
This commit is contained in:
@@ -4,11 +4,13 @@ go 1.26.3
|
||||
|
||||
require (
|
||||
github.com/coder/websocket v1.8.15
|
||||
github.com/coreos/go-oidc/v3 v3.21.0
|
||||
github.com/docker/docker v28.5.2+incompatible
|
||||
github.com/jackc/pgx/v5 v5.10.0
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/prometheus/client_model v0.6.2
|
||||
github.com/stretchr/testify v1.11.1
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -23,6 +25,7 @@ require (
|
||||
github.com/docker/go-connections v0.7.0 // indirect
|
||||
github.com/docker/go-units v0.5.0 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
|
||||
@@ -16,6 +16,8 @@ github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151X
|
||||
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
|
||||
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
|
||||
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
|
||||
github.com/coreos/go-oidc/v3 v3.21.0 h1:wZo4Q9Pum8dYEj0eMUPrqR+kvuGkeUplbLpNCkBqoWM=
|
||||
github.com/coreos/go-oidc/v3 v3.21.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -29,6 +31,8 @@ github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4
|
||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
@@ -119,6 +123,8 @@ go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
|
||||
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
|
||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
// Package webauth logs people into the master UI through OIDC (Authentik),
|
||||
// keeps their sessions, and protects state-changing requests with a CSRF
|
||||
// token.
|
||||
package webauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
"github.com/shcizo/package-updater/internal/master/model"
|
||||
)
|
||||
|
||||
const (
|
||||
sessionCookieName = "pu_session"
|
||||
flowCookieName = "pu_oidc_flow"
|
||||
sessionTTL = 12 * time.Hour
|
||||
)
|
||||
|
||||
// Store keeps sessions. *store.Store satisfies it.
|
||||
type Store interface {
|
||||
CreateSession(ctx context.Context, s model.Session) error
|
||||
GetSession(ctx context.Context, id string, now time.Time) (model.Session, error)
|
||||
DeleteSession(ctx context.Context, id string) error
|
||||
}
|
||||
|
||||
// Auth holds the login handlers and the session middleware.
|
||||
type Auth struct {
|
||||
provider Provider
|
||||
store Store
|
||||
allowedGroup string
|
||||
secure bool
|
||||
log *slog.Logger
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// New returns an Auth. Only members of allowedGroup may log in. secureCookies
|
||||
// should be true whenever the UI is served over HTTPS.
|
||||
func New(p Provider, s Store, allowedGroup string, secureCookies bool, log *slog.Logger) *Auth {
|
||||
return &Auth{provider: p, store: s, allowedGroup: allowedGroup, secure: secureCookies, log: log, now: time.Now}
|
||||
}
|
||||
|
||||
// Login starts the OIDC flow. State and PKCE verifier travel in a short-lived
|
||||
// cookie scoped to /auth/; tampering with it can only break the user's own
|
||||
// login.
|
||||
func (a *Auth) Login(w http.ResponseWriter, r *http.Request) {
|
||||
state := randomString()
|
||||
verifier := oauth2.GenerateVerifier()
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: flowCookieName, Value: state + "." + verifier, Path: "/auth/",
|
||||
MaxAge: 600, HttpOnly: true, Secure: a.secure, SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
http.Redirect(w, r, a.provider.AuthCodeURL(state, verifier), http.StatusFound)
|
||||
}
|
||||
|
||||
// Callback finishes the OIDC flow, checks group membership and creates the
|
||||
// session.
|
||||
func (a *Auth) Callback(w http.ResponseWriter, r *http.Request) {
|
||||
flow, err := r.Cookie(flowCookieName)
|
||||
if err != nil {
|
||||
http.Error(w, "login flow expired, start again", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
state, verifier, ok := strings.Cut(flow.Value, ".")
|
||||
if !ok || subtle.ConstantTimeCompare([]byte(r.URL.Query().Get("state")), []byte(state)) != 1 {
|
||||
http.Error(w, "invalid login state", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{Name: flowCookieName, Path: "/auth/", MaxAge: -1})
|
||||
|
||||
claims, err := a.provider.Exchange(r.Context(), r.URL.Query().Get("code"), verifier)
|
||||
if err != nil {
|
||||
a.log.Warn("login_failed", "error", err)
|
||||
http.Error(w, "login failed", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if !slices.Contains(claims.Groups, a.allowedGroup) {
|
||||
a.log.Warn("login_forbidden", "subject", claims.Subject, "required_group", a.allowedGroup)
|
||||
http.Error(w, "forbidden: you are not a member of the group allowed to use package-updater", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
sess := model.Session{
|
||||
ID: randomString(), Subject: claims.Subject, Name: claims.Name, Email: claims.Email,
|
||||
CSRFToken: randomString(), ExpiresAt: a.now().Add(sessionTTL),
|
||||
}
|
||||
if err := a.store.CreateSession(r.Context(), sess); err != nil {
|
||||
a.log.Error("create_session_failed", "error", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookieName, Value: sess.ID, Path: "/", Expires: sess.ExpiresAt,
|
||||
HttpOnly: true, Secure: a.secure, SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
a.log.Info("login", "subject", claims.Subject, "name", claims.Name)
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
}
|
||||
|
||||
// Logout ends the session. Mount behind Require so it is CSRF-protected.
|
||||
func (a *Auth) Logout(w http.ResponseWriter, r *http.Request) {
|
||||
if s, ok := SessionFrom(r.Context()); ok {
|
||||
if err := a.store.DeleteSession(r.Context(), s.ID); err != nil {
|
||||
a.log.Error("delete_session_failed", "error", err)
|
||||
}
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{Name: sessionCookieName, Path: "/", MaxAge: -1})
|
||||
_, _ = w.Write([]byte("Logged out."))
|
||||
}
|
||||
|
||||
// Require lets a request through only with a valid session, and for
|
||||
// anything but GET/HEAD only with the session's CSRF token in the
|
||||
// X-CSRF-Token header or the csrf_token form field. htmx requests without a
|
||||
// session get 401 + HX-Redirect so the whole page navigates to the login,
|
||||
// instead of the login page being swapped into a fragment. Event-stream
|
||||
// (SSE) requests without a session get a plain 401: a redirect would make
|
||||
// the browser's EventSource retry /auth/login repeatedly and overwrite the
|
||||
// login flow cookie of a login in progress in another tab.
|
||||
func (a *Auth) Require(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
sess, err := a.session(r)
|
||||
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
||||
a.log.Error("session_lookup_failed", "error", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
switch {
|
||||
case r.Header.Get("HX-Request") == "true":
|
||||
w.Header().Set("HX-Redirect", "/auth/login")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
case strings.Contains(r.Header.Get("Accept"), "text/event-stream"):
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
case r.Method == http.MethodGet:
|
||||
http.Redirect(w, r, "/auth/login", http.StatusFound)
|
||||
default:
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
}
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
token := r.Header.Get("X-CSRF-Token")
|
||||
if token == "" {
|
||||
token = r.FormValue("csrf_token")
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(token), []byte(sess.CSRFToken)) != 1 {
|
||||
http.Error(w, "invalid CSRF token", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), sessionKey{}, sess)))
|
||||
})
|
||||
}
|
||||
|
||||
func (a *Auth) session(r *http.Request) (model.Session, error) {
|
||||
c, err := r.Cookie(sessionCookieName)
|
||||
if err != nil {
|
||||
return model.Session{}, model.ErrNotFound
|
||||
}
|
||||
return a.store.GetSession(r.Context(), c.Value, a.now())
|
||||
}
|
||||
|
||||
type sessionKey struct{}
|
||||
|
||||
// SessionFrom returns the session Require stored in ctx.
|
||||
func SessionFrom(ctx context.Context) (model.Session, bool) {
|
||||
s, ok := ctx.Value(sessionKey{}).(model.Session)
|
||||
return s, ok
|
||||
}
|
||||
|
||||
func randomString() string {
|
||||
var b [32]byte
|
||||
_, _ = rand.Read(b[:])
|
||||
return base64.RawURLEncoding.EncodeToString(b[:])
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
package webauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/shcizo/package-updater/internal/master/model"
|
||||
)
|
||||
|
||||
type fakeProvider struct {
|
||||
claims Claims
|
||||
err error
|
||||
gotVer string
|
||||
}
|
||||
|
||||
func (p *fakeProvider) AuthCodeURL(state, _ string) string {
|
||||
return "https://auth.example/authorize?state=" + url.QueryEscape(state)
|
||||
}
|
||||
|
||||
func (p *fakeProvider) Exchange(_ context.Context, _ string, verifier string) (Claims, error) {
|
||||
p.gotVer = verifier
|
||||
return p.claims, p.err
|
||||
}
|
||||
|
||||
type fakeStore struct {
|
||||
mu sync.Mutex
|
||||
sessions map[string]model.Session
|
||||
}
|
||||
|
||||
func newFakeStore() *fakeStore { return &fakeStore{sessions: map[string]model.Session{}} }
|
||||
|
||||
func (f *fakeStore) CreateSession(_ context.Context, s model.Session) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.sessions[s.ID] = s
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) GetSession(_ context.Context, id string, now time.Time) (model.Session, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
s, ok := f.sessions[id]
|
||||
if !ok || !s.ExpiresAt.After(now) {
|
||||
return model.Session{}, model.ErrNotFound
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) DeleteSession(_ context.Context, id string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
delete(f.sessions, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func newAuth(p *fakeProvider, s *fakeStore) *Auth {
|
||||
return New(p, s, "updater-admins", true, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
}
|
||||
|
||||
// startLogin runs /auth/login and returns the flow cookie and the state.
|
||||
func startLogin(t *testing.T, a *Auth) (*http.Cookie, string) {
|
||||
t.Helper()
|
||||
rec := httptest.NewRecorder()
|
||||
a.Login(rec, httptest.NewRequest(http.MethodGet, "/auth/login", nil))
|
||||
require.Equal(t, http.StatusFound, rec.Code)
|
||||
loc, err := url.Parse(rec.Header().Get("Location"))
|
||||
require.NoError(t, err)
|
||||
cookies := rec.Result().Cookies()
|
||||
require.Len(t, cookies, 1)
|
||||
return cookies[0], loc.Query().Get("state")
|
||||
}
|
||||
|
||||
func callback(a *Auth, flow *http.Cookie, state string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodGet, "/auth/callback?code=c&state="+url.QueryEscape(state), nil)
|
||||
req.AddCookie(flow)
|
||||
rec := httptest.NewRecorder()
|
||||
a.Callback(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestLogin_SetsFlowCookieAndRedirects(t *testing.T) {
|
||||
a := newAuth(&fakeProvider{}, newFakeStore())
|
||||
flow, state := startLogin(t, a)
|
||||
assert.True(t, flow.HttpOnly)
|
||||
assert.True(t, flow.Secure)
|
||||
assert.NotEmpty(t, state)
|
||||
}
|
||||
|
||||
func TestCallback_StateMismatchRejected(t *testing.T) {
|
||||
a := newAuth(&fakeProvider{}, newFakeStore())
|
||||
flow, _ := startLogin(t, a)
|
||||
rec := callback(a, flow, "forged")
|
||||
assert.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
}
|
||||
|
||||
func TestCallback_UserOutsideGroupForbidden(t *testing.T) {
|
||||
store := newFakeStore()
|
||||
a := newAuth(&fakeProvider{claims: Claims{Subject: "u", Groups: []string{"family"}}}, store)
|
||||
flow, state := startLogin(t, a)
|
||||
rec := callback(a, flow, state)
|
||||
assert.Equal(t, http.StatusForbidden, rec.Code)
|
||||
assert.Empty(t, store.sessions)
|
||||
}
|
||||
|
||||
func TestCallback_ExchangeFailure(t *testing.T) {
|
||||
a := newAuth(&fakeProvider{err: errors.New("bad code")}, newFakeStore())
|
||||
flow, state := startLogin(t, a)
|
||||
assert.Equal(t, http.StatusUnauthorized, callback(a, flow, state).Code)
|
||||
}
|
||||
|
||||
func TestCallback_CreatesSession(t *testing.T) {
|
||||
store := newFakeStore()
|
||||
p := &fakeProvider{claims: Claims{Subject: "u1", Name: "Samuel", Groups: []string{"updater-admins"}}}
|
||||
a := newAuth(p, store)
|
||||
flow, state := startLogin(t, a)
|
||||
rec := callback(a, flow, state)
|
||||
|
||||
require.Equal(t, http.StatusFound, rec.Code)
|
||||
assert.Equal(t, "/", rec.Header().Get("Location"))
|
||||
assert.NotEmpty(t, p.gotVer, "the PKCE verifier from the flow cookie must reach the exchange")
|
||||
var sessionCookie *http.Cookie
|
||||
for _, c := range rec.Result().Cookies() {
|
||||
if c.Name == sessionCookieName {
|
||||
sessionCookie = c
|
||||
}
|
||||
}
|
||||
require.NotNil(t, sessionCookie)
|
||||
assert.True(t, sessionCookie.HttpOnly)
|
||||
assert.Equal(t, http.SameSiteLaxMode, sessionCookie.SameSite)
|
||||
s, ok := store.sessions[sessionCookie.Value]
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "Samuel", s.Name)
|
||||
assert.NotEmpty(t, s.CSRFToken)
|
||||
}
|
||||
|
||||
func protectedRequest(method, target string, sessionID string) *http.Request {
|
||||
req := httptest.NewRequest(method, target, nil)
|
||||
if sessionID != "" {
|
||||
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: sessionID})
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
func TestRequire(t *testing.T) {
|
||||
store := newFakeStore()
|
||||
store.sessions["live"] = model.Session{ID: "live", Name: "Samuel", CSRFToken: "tok", ExpiresAt: time.Now().Add(time.Hour)}
|
||||
store.sessions["old"] = model.Session{ID: "old", CSRFToken: "tok", ExpiresAt: time.Now().Add(-time.Hour)}
|
||||
a := newAuth(&fakeProvider{}, store)
|
||||
|
||||
var gotName string
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
s, _ := SessionFrom(r.Context())
|
||||
gotName = s.Name
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
h := a.Require(next)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
req *http.Request
|
||||
wantCode int
|
||||
wantHeader [2]string
|
||||
}{
|
||||
{"no session GET redirects to login", protectedRequest(http.MethodGet, "/", ""), http.StatusFound, [2]string{"Location", "/auth/login"}},
|
||||
{"expired session on htmx fragment", func() *http.Request {
|
||||
r := protectedRequest(http.MethodGet, "/fragments/services", "old")
|
||||
r.Header.Set("HX-Request", "true")
|
||||
return r
|
||||
}(), http.StatusUnauthorized, [2]string{"HX-Redirect", "/auth/login"}},
|
||||
{"event-stream without session gets plain 401", func() *http.Request {
|
||||
r := protectedRequest(http.MethodGet, "/events", "")
|
||||
r.Header.Set("Accept", "text/event-stream")
|
||||
return r
|
||||
}(), http.StatusUnauthorized, [2]string{"Location", ""}},
|
||||
{"no session POST", protectedRequest(http.MethodPost, "/agents", ""), http.StatusUnauthorized, [2]string{}},
|
||||
{"POST without CSRF", protectedRequest(http.MethodPost, "/agents", "live"), http.StatusForbidden, [2]string{}},
|
||||
{"POST with CSRF header", func() *http.Request {
|
||||
r := protectedRequest(http.MethodPost, "/agents", "live")
|
||||
r.Header.Set("X-CSRF-Token", "tok")
|
||||
return r
|
||||
}(), http.StatusNoContent, [2]string{}},
|
||||
{"POST with CSRF form field", func() *http.Request {
|
||||
r := httptest.NewRequest(http.MethodPost, "/agents", strings.NewReader("csrf_token=tok"))
|
||||
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "live"})
|
||||
return r
|
||||
}(), http.StatusNoContent, [2]string{}},
|
||||
{"GET with session", protectedRequest(http.MethodGet, "/", "live"), http.StatusNoContent, [2]string{}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, c.req)
|
||||
assert.Equal(t, c.wantCode, rec.Code)
|
||||
if c.wantHeader[0] != "" {
|
||||
assert.Equal(t, c.wantHeader[1], rec.Header().Get(c.wantHeader[0]))
|
||||
}
|
||||
})
|
||||
}
|
||||
assert.Equal(t, "Samuel", gotName)
|
||||
}
|
||||
|
||||
func TestLogout_DeletesSession(t *testing.T) {
|
||||
store := newFakeStore()
|
||||
store.sessions["live"] = model.Session{ID: "live", CSRFToken: "tok", ExpiresAt: time.Now().Add(time.Hour)}
|
||||
a := newAuth(&fakeProvider{}, store)
|
||||
|
||||
req := protectedRequest(http.MethodPost, "/auth/logout", "live")
|
||||
req.Header.Set("X-CSRF-Token", "tok")
|
||||
rec := httptest.NewRecorder()
|
||||
a.Require(http.HandlerFunc(a.Logout)).ServeHTTP(rec, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
assert.Empty(t, store.sessions)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package webauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
// Claims is what the master needs from the identity provider.
|
||||
type Claims struct {
|
||||
Subject string
|
||||
Name string
|
||||
Email string
|
||||
Groups []string
|
||||
}
|
||||
|
||||
// Provider performs the OIDC authorization code flow with PKCE. Declared as
|
||||
// an interface so handlers can be tested without a real identity provider.
|
||||
type Provider interface {
|
||||
AuthCodeURL(state, verifier string) string
|
||||
Exchange(ctx context.Context, code, verifier string) (Claims, error)
|
||||
}
|
||||
|
||||
// OIDCConfig configures the real provider.
|
||||
type OIDCConfig struct {
|
||||
Issuer string // e.g. https://auth.example.com/application/o/package-updater/
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
RedirectURL string // PUBLIC_URL + /auth/callback
|
||||
}
|
||||
|
||||
type oidcProvider struct {
|
||||
oauth *oauth2.Config
|
||||
verifier *oidc.IDTokenVerifier
|
||||
}
|
||||
|
||||
// NewOIDCProvider discovers the issuer's endpoints. It makes a network call,
|
||||
// so the master fails at startup if Authentik is unreachable.
|
||||
func NewOIDCProvider(ctx context.Context, cfg OIDCConfig) (Provider, error) {
|
||||
p, err := oidc.NewProvider(ctx, cfg.Issuer)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("oidc discovery: %w", err)
|
||||
}
|
||||
return &oidcProvider{
|
||||
oauth: &oauth2.Config{
|
||||
ClientID: cfg.ClientID,
|
||||
ClientSecret: cfg.ClientSecret,
|
||||
Endpoint: p.Endpoint(),
|
||||
RedirectURL: cfg.RedirectURL,
|
||||
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
|
||||
},
|
||||
verifier: p.Verifier(&oidc.Config{ClientID: cfg.ClientID}),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *oidcProvider) AuthCodeURL(state, verifier string) string {
|
||||
return p.oauth.AuthCodeURL(state, oauth2.S256ChallengeOption(verifier))
|
||||
}
|
||||
|
||||
func (p *oidcProvider) Exchange(ctx context.Context, code, verifier string) (Claims, error) {
|
||||
tok, err := p.oauth.Exchange(ctx, code, oauth2.VerifierOption(verifier))
|
||||
if err != nil {
|
||||
return Claims{}, fmt.Errorf("exchange code: %w", err)
|
||||
}
|
||||
raw, ok := tok.Extra("id_token").(string)
|
||||
if !ok {
|
||||
return Claims{}, errors.New("token response has no id_token")
|
||||
}
|
||||
idt, err := p.verifier.Verify(ctx, raw)
|
||||
if err != nil {
|
||||
return Claims{}, fmt.Errorf("verify id_token: %w", err)
|
||||
}
|
||||
var c struct {
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Groups []string `json:"groups"`
|
||||
}
|
||||
if err := idt.Claims(&c); err != nil {
|
||||
return Claims{}, fmt.Errorf("read claims: %w", err)
|
||||
}
|
||||
return Claims{Subject: idt.Subject, Name: c.Name, Email: c.Email, Groups: c.Groups}, nil
|
||||
}
|
||||
Reference in New Issue
Block a user