From c7750fbf0bfba7aa9eb5f43f54aac4c2803e15cc Mon Sep 17 00:00:00 2001 From: mrambossek Date: Sat, 1 Aug 2026 18:47:34 +0200 Subject: [PATCH] oidc: one verifier per issuer, because IdPs mint one per application Authentik derives the issuer from the application slug, so two applications mean two issuers - and a token's `iss` must match whoever signed it. A single pinned issuer could therefore only ever serve one of the two clients. So there is a verifier per issuer, and each accepts only the client belonging to it. That is tighter than the previous arrangement as well as more general: a token minted for the phone cannot be replayed at the admin login, and vice versa, because they arrive at different verifiers with different audiences. ECHOLOT_OIDC_APP_ISSUER is optional - empty means both clients share ECHOLOT_OIDC_ISSUER, which is what IdPs with one global issuer do. Co-Authored-By: Claude Fable 5 --- server/cmd/echolot-server/main.go | 32 ++++++++++++++++++++---------- server/internal/config/config.go | 9 ++++++++- server/internal/control/control.go | 6 +++++- 3 files changed, 35 insertions(+), 12 deletions(-) diff --git a/server/cmd/echolot-server/main.go b/server/cmd/echolot-server/main.go index d16b386..1610ad6 100644 --- a/server/cmd/echolot-server/main.go +++ b/server/cmd/echolot-server/main.go @@ -184,22 +184,33 @@ func serve(cfg *config.Config) error { // Identity is optional. Without an issuer the server simply has no sign-in, and // uploads=account can never be satisfied — which is the honest outcome, not a silent // downgrade to anonymous. - var idp *oidc.Verifier - if cfg.OIDCIssuer != "" && (cfg.OIDCClientID != "" || cfg.OIDCAppClientID != "") { + // One verifier per issuer. An IdP may mint a distinct issuer per application — Authentik + // derives it from the application slug — and a token's `iss` must match whoever signed it. + // Each verifier accepts only the client belonging to its own issuer, so a token minted for + // the phone cannot be replayed at the admin login and vice versa. + var idp, adminIdP *oidc.Verifier + appIssuer := cfg.OIDCAppIssuer + if appIssuer == "" { + appIssuer = cfg.OIDCIssuer // IdPs with one global issuer + } + if appIssuer != "" && cfg.OIDCAppClientID != "" { idp = oidc.New(oidc.Config{ - Issuer: cfg.OIDCIssuer, - ClientID: cfg.OIDCClientID, - AppClientID: cfg.OIDCAppClientID, - AdminGroup: cfg.OIDCAdminGroup, + Issuer: appIssuer, AppClientID: cfg.OIDCAppClientID, AdminGroup: cfg.OIDCAdminGroup, }, nil) - slog.Info("identity provider configured", "issuer", cfg.OIDCIssuer, - "admin_client_id", cfg.OIDCClientID, "app_client_id", cfg.OIDCAppClientID, - "admin_group", cfg.OIDCAdminGroup) + slog.Info("identity: app client", "issuer", appIssuer, "client_id", cfg.OIDCAppClientID) + } + if cfg.OIDCIssuer != "" && cfg.OIDCClientID != "" { + adminIdP = oidc.New(oidc.Config{ + Issuer: cfg.OIDCIssuer, ClientID: cfg.OIDCClientID, AdminGroup: cfg.OIDCAdminGroup, + }, nil) + slog.Info("identity: admin client", "issuer", cfg.OIDCIssuer, + "client_id", cfg.OIDCClientID, "admin_group", cfg.OIDCAdminGroup) if cfg.OIDCAdminGroup == "" { slog.Warn("no admin group set: nobody will be an admin via OIDC " + "(set ECHOLOT_OIDC_ADMIN_GROUP)") } - } else if cfg.UploadsMode == string(runs.ModeAccount) { + } + if idp == nil && adminIdP == nil && cfg.UploadsMode == string(runs.ModeAccount) { slog.Warn("uploads=account but no identity provider is configured — " + "every upload will be refused") } @@ -216,6 +227,7 @@ func serve(cfg *config.Config) error { AppRange: appRange, PublicControlURL: publicControlURL(cfg), OIDC: idp, + AdminOIDC: adminIdP, } // Left nil when there is no raw socket, so the handler answers "not implemented" with a // reason rather than failing somewhere deeper. diff --git a/server/internal/config/config.go b/server/internal/config/config.go index d0edfcd..3dcfc23 100644 --- a/server/internal/config/config.go +++ b/server/internal/config/config.go @@ -74,7 +74,13 @@ type Config struct { OIDCIssuer string // ECHOLOT_OIDC_ISSUER / --oidc-issuer OIDCClientID string // ECHOLOT_OIDC_CLIENT_ID / --oidc-client-id (confidential, admin UI) OIDCAppClientID string // ECHOLOT_OIDC_APP_CLIENT_ID / --oidc-app-client-id (public, the phone app) - OIDCAdminGroup string // ECHOLOT_OIDC_ADMIN_GROUP / --oidc-admin-group + // Issuer for the app's client, when the IdP gives each application its own. + // + // Authentik derives the issuer from the application slug, so two applications mean two + // issuers — and a token's `iss` must match the one that minted it. Empty means both clients + // share ECHOLOT_OIDC_ISSUER, which is what IdPs with a single global issuer do. + OIDCAppIssuer string // ECHOLOT_OIDC_APP_ISSUER / --oidc-app-issuer + OIDCAdminGroup string // ECHOLOT_OIDC_ADMIN_GROUP / --oidc-admin-group // Break-glass admin username; the password lives hashed in the state store. AdminUser string // ECHOLOT_ADMIN_USER / --admin-user @@ -167,6 +173,7 @@ func Load(args []string) (*Config, *Actions, error) { fs.StringVar(&c.OIDCIssuer, "oidc-issuer", envOr("OIDC_ISSUER", ""), "OpenID Connect issuer URL; empty disables sign-in") fs.StringVar(&c.OIDCClientID, "oidc-client-id", envOr("OIDC_CLIENT_ID", ""), "confidential OIDC client id for the admin UI") fs.StringVar(&c.OIDCAppClientID, "oidc-app-client-id", envOr("OIDC_APP_CLIENT_ID", ""), "public OIDC client id used by the Android app (PKCE)") + fs.StringVar(&c.OIDCAppIssuer, "oidc-app-issuer", envOr("OIDC_APP_ISSUER", ""), "issuer for the app client when the IdP uses per-application issuers; empty = same as --oidc-issuer") fs.StringVar(&c.OIDCAdminGroup, "oidc-admin-group", envOr("OIDC_ADMIN_GROUP", ""), "group claim required for admin access; empty means nobody is an admin via OIDC") fs.StringVar(&c.OIDCClientSecret, "oidc-client-secret", secretOr("OIDC_CLIENT_SECRET", ""), "secret for the confidential admin client; prefer ECHOLOT_OIDC_CLIENT_SECRET_FILE") fs.StringVar(&c.AdminBaseURL, "admin-base-url", envOr("ADMIN_BASE_URL", ""), "public URL of the admin UI, for the OIDC redirect (e.g. https://admin.example.net)") diff --git a/server/internal/control/control.go b/server/internal/control/control.go index 4ec3b14..506ef8f 100644 --- a/server/internal/control/control.go +++ b/server/internal/control/control.go @@ -59,8 +59,12 @@ type Server struct { // Granted server->client sends (spec §5). Both consume an asymmetric grant. DownTrain func(sess *session.Session, g *session.Grant, count, sizeBytes, intervalUs int) (int, error) BigSend func(sess *session.Session, g *session.Grant, sizes []int, df bool) ([]dataplane.BigSendResult, error) - // OIDC verifies ID tokens when the operator has configured an issuer (may be nil). + // OIDC verifies ID tokens presented by the *app* (may be nil). OIDC *oidc.Verifier + // AdminOIDC verifies tokens from the admin UI's own client. Separate because an IdP may + // give each application its own issuer — Authentik derives it from the application slug — + // and a verifier pins exactly one issuer and the clients belonging to it. + AdminOIDC *oidc.Verifier // Runs stores uploaded measurement documents (may be nil: uploads unsupported). Runs *runs.Store // FragSend emits one datagram as hand-built IP fragments in a chosen order (may be nil: