Skip to content

Revamp Authentication: API tokens, roles, and the oidc rename - #441

Open
olblak wants to merge 14 commits into
updatecli:mainfrom
olblak:feat/api-tokens
Open

Revamp Authentication: API tokens, roles, and the oidc rename#441
olblak wants to merge 14 commits into
updatecli:mainfrom
olblak:feat/api-tokens

Conversation

@olblak

@olblak olblak commented Aug 14, 2026

Copy link
Copy Markdown
Member

Description

Updatecli needs a credential that does not expire so an unattended pipeline
can keep publishing. An identity provider access token always expires, so Udash
now issues its own long-lived tokens and validates them itself.

That needed authorization first. Before this change it was binary and global,
"has a valid token", plus optionally one role. And role checking existed only
in zitadel mode. Under oauth mode any valid token could write. Without
fixing that, on any instance where the IdP allows self-registration, everybody who
could sign in could mint a publishing token.

1. oauth mode renamed to oidc

pkg/server/jwt.go was never Auth0-specific — it is generic OIDC (JWKS caching,
RS256, issuer + audience validation) and auth0/go-jwt-middleware is just the
library name. It works with Keycloak, Okta, Auth0, Dex. It is kept and renamed.

The two modes are complementary, not redundant:

mode validation opaque tokens providers
oidc (was oauth) local JWKS verify ✗ JWT only any OIDC provider
zitadel RFC 7662 introspection, JWT-profile client auth Zitadel

That second row is why a Zitadel machine-user PAT already works as a permanent
credential — PATs are opaque, so only the introspection mode can validate one.
Dropping oidc would lose multi-provider support; dropping zitadel would lose
PATs.

Renamed: ModeOauthModeOIDC, OauthOptionsOIDCOptions,
AuthOptions.Oauth.OIDC, optionOauth.gooptionAuth.go, viper
server.auth.oauth.*server.auth.oidc.*, env UDASH_AUTH_OAUTH_*
UDASH_AUTH_OIDC_*, and UDASH_AUTH_ZITADEL_FILEKEY_KEYFILE to match the
KeyFile field it fills.

2. The auth switch no longer fails open

newGinEngine's switch had no default branch. A typo'd mode registered
zero middleware and left POST/PUT/DELETE wide open, while Init() merely
logged an error and let the server boot. An unknown mode or visibility is now
fatal.

Init() returns an error (was void), threaded through Options.Init() and
Server.Run(). checkJWT takes AuthOptions instead of reading a package
global, which is what previously made the middleware untestable.

3. Permissions and roles

Two axes.

Permissions, from IdP roles via server.auth.roles.mapping:

Permission Grants
viewer read reports
publisher publish reports, create API tokens
admin everything, plus managing any identity's tokens

Token scopes, chosen at creation and never exceeding the creator's
permission: reports:read, reports:write. There is deliberately no
tokens-management scope, a token can never mint another one.

POST /api/tokens requires publisher. This is what stops arbitrary signed-in
users creating publishing tokens in the frontend.

Role claims are provider-agnostic

server.auth.roles.claim with per-mode defaults. Providers disagree on both name
and shape, and the extractor handles both:

Provider Claim Shape
Zitadel urn:zitadel:iam:org:project:roles object — role names are the keys
Keycloak realm_access.roles (dotted path) array of strings
Auth0/Okta https://udash/roles array of strings

RoleResolver — the one provider-specific part

A request authenticating with an Udash token carries no IdP token, so there
are no claims to read roles from — only the creator's subject. Looking roles up by
subject needs a management API, which is genuinely provider-specific.

  • zitadelListUserGrants via the service-account key file udash already
    holds. Revoking a role in Zitadel downgrades tokens created before it. The
    service user must be allowed to read user grants.
    Cached (cacheTTL, 60s
    default) so a publish-heavy pipeline does not hammer the IdP.
  • snapshot — trusts the permission recorded at creation. The only option for
    other providers; offboarding then means deleting the identity's tokens.

Two invariants, both tested: a resolver never grants more than the token was
created with (promotion does not leak into old tokens), and an IdP outage falls
back to the recorded permission rather than failing every publish.

4. API tokens

Migration 000012_create_api_tokens. 32 random bytes, base64url, prefixed
udash_pat_ — the prefix makes the middleware branch unambiguous and lets secret
scanners spot a leak. Only sha256(token) is stored; the token is returned once.

udashTokenAuth runs before and independently of the mode middleware, so
Udash tokens behave identically whichever provider is configured.

Method Path Guard
POST /api/tokens publisher, and rejected if authenticated with a token
GET /api/tokens own; ?all=true needs admin
DELETE /api/tokens/:id own unless admin
DELETE /api/tokens?subject= admin — offboarding
GET /api/whoami any principal; used by updatecli udash login

Report write routes gained requireScope("reports:write").

Token routes live on their own group, never apiPipeline — that group is left
open for reads under visibility: public.

5. Report attribution

Nothing carried identity before: CreatePipelineReport stored nothing about the
caller, CustomClaims was parsed then discarded, and the Zitadel auth context was
set but never read back.

New pkg/server/identity.go defines Principal and all three middlewares
populate it. Migration 000013 adds nullable created_by_subject and
created_by_token_id to pipelineReports, so existing rows and mode: none
deployments keep working.

Test

To test this pull request, you can run the following commands:

go build ./... && go vet ./... && go test ./...

Additional Information

Tradeoff

Potential improvement

olblak and others added 12 commits August 4, 2026 20:38
Signed-off-by: Olivier Vernin <olivier@vernin.me>
Signed-off-by: Olivier Vernin <olivier@vernin.me>
Signed-off-by: Olivier Vernin <olivier@vernin.me>
* Set pagination limit
* Use middleware for publicReadOnly endpoint
* Zitadel requires a valid token and a configured role
* Accept bare url for auth zero issuer URL
* Validate timerange param
* Allow to filter getscm query based date filter
* Correctly close pg connection
* fix report query qq

Signed-off-by: Olivier Vernin <olivier@vernin.me>
Signed-off-by: Olivier Vernin <olivier@vernin.me>
Signed-off-by: Olivier Vernin <olivier@vernin.me>
Signed-off-by: Olivier Vernin <olivier@vernin.me>
Signed-off-by: Olivier Vernin <olivier@vernin.me>
Signed-off-by: Olivier Vernin <olivier@vernin.me>
@olblak olblak added the enhancement New feature or request label Aug 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant