Compare commits
2 Commits
2463129dbd
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 7b2f2fae30 | |||
| b02b1706b6 |
@@ -127,7 +127,7 @@ func check(label string, err error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func usage() {
|
func usage() {
|
||||||
fmt.Fprintln(os.Stderr, `Usage:
|
fmt.Fprint(os.Stderr, `Usage:
|
||||||
dialectic-cli config oidc [--issuer <url>] [--client-id <id>]
|
dialectic-cli config oidc [--issuer <url>] [--client-id <id>]
|
||||||
[--client-secret <s>] [--callback-url <url>]
|
[--client-secret <s>] [--callback-url <url>]
|
||||||
[--post-login-redirect <url>]
|
[--post-login-redirect <url>]
|
||||||
|
|||||||
185
internal/auth/oidc_bearer.go
Normal file
185
internal/auth/oidc_bearer.go
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/coreos/go-oidc/v3/oidc"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
errNoSub = errors.New("token missing sub")
|
||||||
|
errBadAudience = errors.New("token audience does not match")
|
||||||
|
)
|
||||||
|
|
||||||
|
// TesseraBearer middleware: accepts access tokens issued by the external
|
||||||
|
// "Tessera" OIDC provider (Keycloak-compatible) as API bearer tokens.
|
||||||
|
//
|
||||||
|
// This is ADDITIVE to the existing agent-key and browser-session auth
|
||||||
|
// paths. It only acts when the request carries `Authorization: Bearer
|
||||||
|
// <jwt>` AND that bearer is a parseable, verifiable Tessera JWT. Agent
|
||||||
|
// keys are opaque (not JWTs), so they fail verification here and the
|
||||||
|
// chain falls through to the next auth step — nothing breaks.
|
||||||
|
//
|
||||||
|
// On success it attaches a CallerUser:
|
||||||
|
//
|
||||||
|
// Caller{
|
||||||
|
// Kind: CallerUser,
|
||||||
|
// ID: sub,
|
||||||
|
// Email: email,
|
||||||
|
// Name: preferred_username,
|
||||||
|
// Roles: realm_access.roles ++ resource_access[audience].roles,
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// On any failure (missing/opaque bearer, bad signature, wrong issuer,
|
||||||
|
// wrong audience, expired) it 401s — the caller composes this into the
|
||||||
|
// auth chain the same way as AgentAPIKey (capture 401 → fall through).
|
||||||
|
func TesseraBearer(issuer, audience string) func(http.Handler) http.Handler {
|
||||||
|
v := newTesseraVerifier(issuer, audience)
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
raw := bearerToken(r)
|
||||||
|
if raw == "" {
|
||||||
|
http.Error(w, "missing bearer token", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
caller, err := v.verify(r.Context(), raw)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid tessera bearer", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := WithCaller(r.Context(), caller)
|
||||||
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// tesseraVerifier wraps a go-oidc IDTokenVerifier over the issuer's JWKS.
|
||||||
|
// The provider (and thus its cached KeySet) is lazily fetched on first
|
||||||
|
// use and refreshed hourly — same defensive TTL the OIDC login service
|
||||||
|
// uses. go-oidc's RemoteKeySet handles per-kid JWKS fetching + caching
|
||||||
|
// internally; this wrapper just adds the discovery cache + claim mapping.
|
||||||
|
type tesseraVerifier struct {
|
||||||
|
issuer string
|
||||||
|
audience string
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
verifier *oidc.IDTokenVerifier
|
||||||
|
fetchedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTesseraVerifier(issuer, audience string) *tesseraVerifier {
|
||||||
|
return &tesseraVerifier{issuer: issuer, audience: audience}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *tesseraVerifier) getVerifier(ctx context.Context) (*oidc.IDTokenVerifier, error) {
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
if t.verifier != nil && time.Since(t.fetchedAt) < time.Hour {
|
||||||
|
return t.verifier, nil
|
||||||
|
}
|
||||||
|
p, err := oidc.NewProvider(ctx, t.issuer)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// SkipClientIDCheck: Keycloak access tokens carry the app client id in
|
||||||
|
// `aud`, but go-oidc's default aud check compares against ClientID. We
|
||||||
|
// do the audience check ourselves in verify() so we can accept aud as
|
||||||
|
// either a string or an array, so disable the built-in one here.
|
||||||
|
v := p.Verifier(&oidc.Config{
|
||||||
|
SkipClientIDCheck: true,
|
||||||
|
SupportedSigningAlgs: []string{oidc.RS256},
|
||||||
|
})
|
||||||
|
t.verifier = v
|
||||||
|
t.fetchedAt = time.Now()
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// tesseraClaims is the projection of a Tessera/Keycloak access token we
|
||||||
|
// read. aud is decoded loosely (string or array) via audience.
|
||||||
|
type tesseraClaims struct {
|
||||||
|
Sub string `json:"sub"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
PreferredUsername string `json:"preferred_username"`
|
||||||
|
Aud audience `json:"aud"`
|
||||||
|
RealmAccess struct {
|
||||||
|
Roles []string `json:"roles"`
|
||||||
|
} `json:"realm_access"`
|
||||||
|
ResourceAccess map[string]struct {
|
||||||
|
Roles []string `json:"roles"`
|
||||||
|
} `json:"resource_access"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *tesseraVerifier) verify(ctx context.Context, raw string) (Caller, error) {
|
||||||
|
v, err := t.getVerifier(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return Caller{}, err
|
||||||
|
}
|
||||||
|
// Verify() checks RS256 signature against JWKS, iss == issuer, and exp.
|
||||||
|
tok, err := v.Verify(ctx, raw)
|
||||||
|
if err != nil {
|
||||||
|
return Caller{}, err
|
||||||
|
}
|
||||||
|
var c tesseraClaims
|
||||||
|
if err := tok.Claims(&c); err != nil {
|
||||||
|
return Caller{}, err
|
||||||
|
}
|
||||||
|
if c.Sub == "" {
|
||||||
|
return Caller{}, errNoSub
|
||||||
|
}
|
||||||
|
if !c.Aud.contains(t.audience) {
|
||||||
|
return Caller{}, errBadAudience
|
||||||
|
}
|
||||||
|
roles := append([]string{}, c.RealmAccess.Roles...)
|
||||||
|
if ra, ok := c.ResourceAccess[t.audience]; ok {
|
||||||
|
roles = append(roles, ra.Roles...)
|
||||||
|
}
|
||||||
|
name := c.PreferredUsername
|
||||||
|
return Caller{
|
||||||
|
Kind: CallerUser,
|
||||||
|
ID: c.Sub,
|
||||||
|
Email: c.Email,
|
||||||
|
Name: name,
|
||||||
|
Roles: roles,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// audience decodes the JWT `aud` claim, which may be a single string or
|
||||||
|
// an array of strings.
|
||||||
|
type audience []string
|
||||||
|
|
||||||
|
func (a *audience) UnmarshalJSON(b []byte) error {
|
||||||
|
b = []byte(strings.TrimSpace(string(b)))
|
||||||
|
if len(b) == 0 || string(b) == "null" {
|
||||||
|
*a = nil
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if b[0] == '[' {
|
||||||
|
var arr []string
|
||||||
|
if err := json.Unmarshal(b, &arr); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
*a = arr
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var s string
|
||||||
|
if err := json.Unmarshal(b, &s); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
*a = []string{s}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a audience) contains(want string) bool {
|
||||||
|
for _, v := range a {
|
||||||
|
if v == want {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -71,6 +71,16 @@ type Config struct {
|
|||||||
OIDCIssuer string
|
OIDCIssuer string
|
||||||
OIDCClientID string
|
OIDCClientID string
|
||||||
|
|
||||||
|
// OIDCBearerIssuer + OIDCBearerAudience configure acceptance of
|
||||||
|
// access tokens issued by the external "Tessera" OIDC provider
|
||||||
|
// (Keycloak-compatible) as API bearer tokens. ADDITIVE to the
|
||||||
|
// existing agent-key + browser-session auth paths. A request with
|
||||||
|
// `Authorization: Bearer <jwt>` whose JWT verifies against this
|
||||||
|
// issuer's JWKS and carries this audience is treated as a CallerUser.
|
||||||
|
// Env: OIDC_BEARER_ISSUER, OIDC_BEARER_AUDIENCE.
|
||||||
|
OIDCBearerIssuer string
|
||||||
|
OIDCBearerAudience string
|
||||||
|
|
||||||
// OIDC_ONLY: when "true", disables the dev-bypass auth path on
|
// OIDC_ONLY: when "true", disables the dev-bypass auth path on
|
||||||
// every browser-facing route. Use this in prod once the OIDC
|
// every browser-facing route. Use this in prod once the OIDC
|
||||||
// realm + client are configured so a leaked dev token can't
|
// realm + client are configured so a leaked dev token can't
|
||||||
@@ -112,6 +122,8 @@ func LoadFromEnv() (*Config, error) {
|
|||||||
DialecticAdminAPIKey: os.Getenv("DIALECTIC_ADMIN_API_KEY"),
|
DialecticAdminAPIKey: os.Getenv("DIALECTIC_ADMIN_API_KEY"),
|
||||||
OIDCIssuer: os.Getenv("OIDC_ISSUER"),
|
OIDCIssuer: os.Getenv("OIDC_ISSUER"),
|
||||||
OIDCClientID: os.Getenv("OIDC_CLIENT_ID"),
|
OIDCClientID: os.Getenv("OIDC_CLIENT_ID"),
|
||||||
|
OIDCBearerIssuer: getenv("OIDC_BEARER_ISSUER", "https://login.hangman-lab.top/realms/Hangman-Lab"),
|
||||||
|
OIDCBearerAudience: getenv("OIDC_BEARER_AUDIENCE", "dialectic-prod"),
|
||||||
OIDCOnly: os.Getenv("OIDC_ONLY") == "true",
|
OIDCOnly: os.Getenv("OIDC_ONLY") == "true",
|
||||||
SessionSigningKey: os.Getenv("SESSION_SIGNING_KEY"),
|
SessionSigningKey: os.Getenv("SESSION_SIGNING_KEY"),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,16 +21,56 @@ import (
|
|||||||
// GET /api/auth/me — current session user (401 if anon)
|
// GET /api/auth/me — current session user (401 if anon)
|
||||||
// POST /api/auth/logout — clears the session cookie
|
// POST /api/auth/logout — clears the session cookie
|
||||||
type AuthHandler struct {
|
type AuthHandler struct {
|
||||||
oidc *oidc.Service
|
oidc *oidc.Service
|
||||||
cookieName string
|
cookieName string
|
||||||
secure bool
|
secure bool
|
||||||
|
allowedHosts []string // for open-redirect protection on post_login_redirect
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAuthHandler(svc *oidc.Service, cookieName string, secure bool) *AuthHandler {
|
// NewAuthHandler wires the OIDC HTTP surface. `allowedHosts` is the
|
||||||
|
// allow-list of hostnames the callback may 302 the browser to (sourced
|
||||||
|
// from cfg.CORSAllowOrigins — the same origins we already trust for
|
||||||
|
// CORS, by definition. A relative path always passes regardless.
|
||||||
|
func NewAuthHandler(svc *oidc.Service, cookieName string, secure bool, allowedHosts []string) *AuthHandler {
|
||||||
if cookieName == "" {
|
if cookieName == "" {
|
||||||
cookieName = "dialectic_session"
|
cookieName = "dialectic_session"
|
||||||
}
|
}
|
||||||
return &AuthHandler{oidc: svc, cookieName: cookieName, secure: secure}
|
return &AuthHandler{oidc: svc, cookieName: cookieName, secure: secure, allowedHosts: allowedHosts}
|
||||||
|
}
|
||||||
|
|
||||||
|
// safeRedirectBase returns `raw` if it's a relative path OR an absolute
|
||||||
|
// URL whose host appears in the allow-list. Otherwise it returns "/" so
|
||||||
|
// the open-redirect attack (attacker-set PostLoginRedirect points to
|
||||||
|
// evil.com) is neutered — the worst the SPA sees is its own root with
|
||||||
|
// an error fragment, never an external bounce.
|
||||||
|
//
|
||||||
|
// Why we need this: oidc_config.post_login_redirect is set via the
|
||||||
|
// admin cli. An admin-key compromise OR a misconfiguration that points
|
||||||
|
// it at an external domain would otherwise let any /oidc/callback
|
||||||
|
// error path redirect the user there with `?oidc_error=...` attached,
|
||||||
|
// usable for phishing ("dialectic login failed: please re-enter your
|
||||||
|
// password at evil.com/login").
|
||||||
|
func (h *AuthHandler) safeRedirectBase(raw string) string {
|
||||||
|
if raw == "" {
|
||||||
|
return "/"
|
||||||
|
}
|
||||||
|
// Relative paths are always safe (same-origin by definition).
|
||||||
|
if strings.HasPrefix(raw, "/") && !strings.HasPrefix(raw, "//") {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
u, err := url.Parse(raw)
|
||||||
|
if err != nil || u.Host == "" {
|
||||||
|
return "/"
|
||||||
|
}
|
||||||
|
// Walk the allow-list. cors.AllowedOrigins entries look like
|
||||||
|
// "https://dialectic.hangman-lab.top" — parse host out + compare.
|
||||||
|
for _, origin := range h.allowedHosts {
|
||||||
|
o, err := url.Parse(origin)
|
||||||
|
if err == nil && o.Host != "" && strings.EqualFold(o.Host, u.Host) {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "/"
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AuthHandler) Status(w http.ResponseWriter, r *http.Request) {
|
func (h *AuthHandler) Status(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -60,8 +100,8 @@ func (h *AuthHandler) Callback(w http.ResponseWriter, r *http.Request) {
|
|||||||
// something useful instead of a 500 page mid-login.
|
// something useful instead of a 500 page mid-login.
|
||||||
c, _ := h.oidc.GetConfig(r.Context())
|
c, _ := h.oidc.GetConfig(r.Context())
|
||||||
base := "/"
|
base := "/"
|
||||||
if c != nil && c.PostLoginRedirect != "" {
|
if c != nil {
|
||||||
base = c.PostLoginRedirect
|
base = h.safeRedirectBase(c.PostLoginRedirect)
|
||||||
}
|
}
|
||||||
sep := "#"
|
sep := "#"
|
||||||
if strings.Contains(base, "#") {
|
if strings.Contains(base, "#") {
|
||||||
@@ -70,11 +110,15 @@ func (h *AuthHandler) Callback(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Redirect(w, r, base+sep+"oidc_error="+url.QueryEscape(err.Error()), http.StatusFound)
|
http.Redirect(w, r, base+sep+"oidc_error="+url.QueryEscape(err.Error()), http.StatusFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Validate the success-path redirect too — HandleCallback returned
|
||||||
|
// PostLoginRedirect from the same DB row, so the same open-redirect
|
||||||
|
// risk applies on the happy path.
|
||||||
|
safe := h.safeRedirectBase(redirect)
|
||||||
sep := "#"
|
sep := "#"
|
||||||
if strings.Contains(redirect, "#") {
|
if strings.Contains(safe, "#") {
|
||||||
sep = "&"
|
sep = "&"
|
||||||
}
|
}
|
||||||
http.Redirect(w, r, redirect+sep+"oidc_ticket="+url.QueryEscape(ticket), http.StatusFound)
|
http.Redirect(w, r, safe+sep+"oidc_ticket="+url.QueryEscape(ticket), http.StatusFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
type exchangeBody struct {
|
type exchangeBody struct {
|
||||||
|
|||||||
@@ -87,7 +87,13 @@ func Mount(cfg *config.Config, db *sqlx.DB, oidcSvc *oidc.Service, version strin
|
|||||||
// cookie would prevent the browser from sending it back. The
|
// cookie would prevent the browser from sending it back. The
|
||||||
// SameSite=Lax + HttpOnly defenses still apply; CF/origin TLS
|
// SameSite=Lax + HttpOnly defenses still apply; CF/origin TLS
|
||||||
// covers the wire.
|
// covers the wire.
|
||||||
authH := handlers.NewAuthHandler(oidcSvc, "dialectic_session", false)
|
// Pass the configured CORS allow-list as the open-redirect allowlist
|
||||||
|
// for the OIDC callback — the SPA hosts we already trust for CORS
|
||||||
|
// are by definition the same hosts a legitimate PostLoginRedirect
|
||||||
|
// can target. Anything else (incl. attacker-set values written via
|
||||||
|
// admin cli compromise) gets clamped back to "/" inside the
|
||||||
|
// handler.
|
||||||
|
authH := handlers.NewAuthHandler(oidcSvc, "dialectic_session", false, cfg.CORSAllowOrigins)
|
||||||
|
|
||||||
// Routes.
|
// Routes.
|
||||||
r.Route("/api", func(r chi.Router) {
|
r.Route("/api", func(r chi.Router) {
|
||||||
@@ -152,6 +158,7 @@ func Mount(cfg *config.Config, db *sqlx.DB, oidcSvc *oidc.Service, version strin
|
|||||||
// to do with anonymous (typically: serve public subset, hide private).
|
// to do with anonymous (typically: serve public subset, hide private).
|
||||||
func optionalAuthChain(db *sqlx.DB, cfg *config.Config, verifier auth.SessionVerifier) func(http.Handler) http.Handler {
|
func optionalAuthChain(db *sqlx.DB, cfg *config.Config, verifier auth.SessionVerifier) func(http.Handler) http.Handler {
|
||||||
agent := auth.AgentAPIKey(db, cfg.AgentAPIKeyPepper)
|
agent := auth.AgentAPIKey(db, cfg.AgentAPIKeyPepper)
|
||||||
|
tessera := auth.TesseraBearer(cfg.OIDCBearerIssuer, cfg.OIDCBearerAudience)
|
||||||
oidcMw := auth.OIDCBrowser(verifier, cfg.IsDev(), cfg.OIDCDevBypassToken, cfg.OIDCOnly)
|
oidcMw := auth.OIDCBrowser(verifier, cfg.IsDev(), cfg.OIDCDevBypassToken, cfg.OIDCOnly)
|
||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -166,6 +173,14 @@ func optionalAuthChain(db *sqlx.DB, cfg *config.Config, verifier auth.SessionVer
|
|||||||
if rw.status != http.StatusUnauthorized {
|
if rw.status != http.StatusUnauthorized {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Not an agent key (opaque) → try a Tessera (external OIDC)
|
||||||
|
// bearer JWT. Opaque agent keys fail JWT parse here and 401,
|
||||||
|
// which we again demote to "fall through".
|
||||||
|
rw = &captureWriter{ResponseWriter: w}
|
||||||
|
tessera(next).ServeHTTP(rw, r)
|
||||||
|
if rw.status != http.StatusUnauthorized {
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Try OIDC (session cookie) — always (no header gate needed,
|
// Try OIDC (session cookie) — always (no header gate needed,
|
||||||
// cookie presence is implicit) so an authenticated browser
|
// cookie presence is implicit) so an authenticated browser
|
||||||
@@ -186,6 +201,7 @@ func optionalAuthChain(db *sqlx.DB, cfg *config.Config, verifier auth.SessionVer
|
|||||||
// requireAnyAuthChain: 401 if neither agent nor user auth succeeds.
|
// requireAnyAuthChain: 401 if neither agent nor user auth succeeds.
|
||||||
func requireAnyAuthChain(db *sqlx.DB, cfg *config.Config, verifier auth.SessionVerifier) func(http.Handler) http.Handler {
|
func requireAnyAuthChain(db *sqlx.DB, cfg *config.Config, verifier auth.SessionVerifier) func(http.Handler) http.Handler {
|
||||||
agent := auth.AgentAPIKey(db, cfg.AgentAPIKeyPepper)
|
agent := auth.AgentAPIKey(db, cfg.AgentAPIKeyPepper)
|
||||||
|
tessera := auth.TesseraBearer(cfg.OIDCBearerIssuer, cfg.OIDCBearerAudience)
|
||||||
oidcMw := auth.OIDCBrowser(verifier, cfg.IsDev(), cfg.OIDCDevBypassToken, cfg.OIDCOnly)
|
oidcMw := auth.OIDCBrowser(verifier, cfg.IsDev(), cfg.OIDCDevBypassToken, cfg.OIDCOnly)
|
||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -195,6 +211,12 @@ func requireAnyAuthChain(db *sqlx.DB, cfg *config.Config, verifier auth.SessionV
|
|||||||
if rw.status != http.StatusUnauthorized {
|
if rw.status != http.StatusUnauthorized {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Not an agent key → try a Tessera (external OIDC) bearer JWT.
|
||||||
|
rw = &captureWriter{ResponseWriter: w}
|
||||||
|
tessera(next).ServeHTTP(rw, r)
|
||||||
|
if rw.status != http.StatusUnauthorized {
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
oidcMw(next).ServeHTTP(w, r)
|
oidcMw(next).ServeHTTP(w, r)
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user