oidc: accept both the app's public client and the server's confidential one
server-test / test (push) Successful in 37s

Explaining public vs confidential clients surfaced a gap in my own design: I had
assumed a single client id, but there are two clients here with genuinely
different properties.

  the Android app     public + PKCE, because an APK cannot keep a secret
  the admin UI        confidential, because the server can keep one in
                      /etc/echolot-server.env and weakening it to public buys
                      nothing

So the audience check now accepts either registered client id - and only those
two. "Any client of this issuer" would let every other application registered
with the same IdP authenticate here, which is the entire reason the check
exists. Either id alone is enough to enable sign-in, since an operator may
register only the app or only the admin UI.

The profile advertises the *app's* client id, since that is what a phone should
authorize as.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
mrambossek
2026-08-01 17:31:33 +02:00
co-authored by Claude Fable 5
parent 89a5ff9139
commit 80d2092f1b
5 changed files with 88 additions and 17 deletions
+33 -4
View File
@@ -97,8 +97,16 @@ func (a audience) contains(s string) bool {
type Config struct {
// Issuer is the IdP's base URL, e.g. https://auth.example.net/application/o/echolot/
Issuer string
// ClientID is this server's registered client. Tokens must be addressed to it.
// ClientID is this server's own registered client — confidential, used for the admin UI's
// browser login, where a secret can genuinely be kept in the host's config.
ClientID string
// AppClientID is the mobile app's registered client. It is a separate, *public* client
// because an APK cannot keep a secret, so it uses PKCE instead.
//
// Both are accepted as audiences, and they must be listed rather than merged: a token is
// addressed to a specific client, and accepting "any client of this issuer" would let every
// other application registered with the same IdP authenticate here.
AppClientID string
// AdminGroup, when set, is the group claim a person must hold to reach the admin UI.
// Empty means no one is an admin via OIDC, which is the safe default: an operator who has
// not said who may administer the server has not said "everyone".
@@ -107,7 +115,27 @@ type Config struct {
Skew time.Duration
}
func (c Config) Enabled() bool { return c.Issuer != "" && c.ClientID != "" }
func (c Config) Enabled() bool { return c.Issuer != "" && (c.ClientID != "" || c.AppClientID != "") }
// acceptedAudiences is every client id this server answers for.
func (v *Verifier) acceptedAudiences() []string {
out := make([]string, 0, 2)
for _, id := range []string{v.cfg.ClientID, v.cfg.AppClientID} {
if id != "" {
out = append(out, id)
}
}
return out
}
func (v *Verifier) audienceAccepted(aud audience) bool {
for _, id := range v.acceptedAudiences() {
if aud.contains(id) {
return true
}
}
return false
}
// Discovery is the subset of the provider metadata document that is used.
type Discovery struct {
@@ -362,8 +390,9 @@ func (v *Verifier) checkClaims(c Claims) error {
}
// A token addressed to a different client is a valid token that was not meant for us —
// accepting it lets any other client of the same IdP authenticate here.
if !c.Audience.contains(v.cfg.ClientID) {
return fmt.Errorf("%w: addressed to %v, not to %q", ErrClaims, []string(c.Audience), v.cfg.ClientID)
if !v.audienceAccepted(c.Audience) {
return fmt.Errorf("%w: addressed to %v, not to %v", ErrClaims,
[]string(c.Audience), v.acceptedAudiences())
}
if c.Subject == "" {
return fmt.Errorf("%w: no subject", ErrClaims)