mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-03 16:02:12 +00:00
6.6 KiB
6.6 KiB
Auth surface: facts that are easy to get wrong
Symbols, not line numbers, are cited: they drift less.
- Credential precedence (
windmill-api-auth/src/auth.rsextract_token):Authorization: Bearer→tokencookie →?token=query param. A URL with?token=is a credential on every route, but an existing cookie silently wins over it. AUTH_CACHEcaches a token's identity for 120 s. Deleting a token row does not purge it: the DB trigger (migrations/20260316000001_token_hash_pk_swap.up.sql) notifies only forlabel = 'session'rows, anddelete_tokennever callsinvalidate_token_from_cache.- Sessions are
tokenrows withlabel='session'plus the HttpOnlytokencookie, minted only bycreate_session_token(windmill-api-users/src/users.rs).GET /api/users/refresh_tokenmints one for any non-job token but returns plain text, no redirect. tokens/impersonate(superadmin) returns a multi-use token and sets no cookie.max_token_expiration_dayscapsPOST /users/tokens/createandtokens/impersonate, by shortening the stored expiration (cap_token_expiration), never by refusing: the CLI authorization page,wmill user create-tokenand the editor's language-server token all pick a lifetime without reading the setting, and CLIs already installed never will. The CLI signs in again on its own when its token expires, which is why the authorization page labels itcli-login:<username>, reserved inis_user_tokenso its expiry does not email the user. A token owned by a service account is exempt: one in the workspace the token names, or in any workspace for a workspace-less token (fortokens/impersonate, the impersonated account). Any workspace admin can therefore create and impersonate a service account to hold an uncapped token, so the ceiling bounds personal tokens only. Only the stored expiration is capped: the auth lookup never reads the setting, so tokens that exist when it is turned on or lowered keep theirs, including none. Deliberately outside it: server-side mints (create_token_internalcallers such as native trigger webhook tokens, which never expire for GitHub and Nextcloud), and tokens with their own fixed lifetime that outlive a short ceiling: sessions (MAX_SESSION_VALIDITY_SECONDS, 3 days, and re-mintable throughGET /users/refresh_token) and MCP OAuth access tokens (7 days, with a rotating 30-day refresh token). Any logged-in user can read the setting throughGET /settings/global/{key}, which the token form uses to offer only expirations within it. The settings API and config sync reject any valueparse_max_token_expiration_dayscannot read, since the token routes would read it as no ceiling;parseMaxTokenExpirationDaysin the frontend must accept exactly the same values.- A token's label decides whether its expiry raises alerts. When
delete_expired_itemsremoves an expiredtokenrow, the monitor emails the owner and raises a critical alert (if enabled); rows registered byregister_token_expiry_notificationalso get an "expiring soon" warning first, except a token whose whole lifetime fits inTOKEN_EXPIRY_WARNING_DAYS(7), which gets no row since the warning would arrive minutes after it was created. Neither happens whenis_user_token(windmill-common/src/auth.rs) reserves the label, so a token the system mints for itself, whether from the backend or from the frontend throughtokens/create, needs a reserved label. Anephemeral-prefix needs no other change (keep it clear ofis_server_minted_labelif minted throughtokens/create); a new prefix also goes into the SQL and Svelte mirrors that function's doc lists. - Every superadmin route refuses a job token:
require_super_admin(windmill-api-auth/src/lib.rs) errors onauthed.job_id.is_some(). A script that needsusers/create,tokens/impersonate,set_login_type, … must use a dedicated superadmin user token stored as a secret, never$WM_TOKEN. Token scopes cannot narrow superadmin routes. login_type(passwordtable) is a free-formVARCHAR(50). Password login and password reset requirelogin_type = 'password';set_passwordalso acceptspending_oauthand turns the account into apasswordone in the same statement (an account created ahead of its owner gets its first credential that way, or through the OAuth claim below).- Login links (
login_linktable,POST /users/login_linkssuperadmin-only,GET /auth/login_link/{token}unauthenticated): single-use, ≤15 min, a session cookie and a 302 to a same-originrd.require_login_typeon the mint refuses (409) an account whoselogin_typehas moved on — the way a caller re-entering an account it created stops being able to once the owner has a password or a provider. - Pre-approved trial offer (
cloud_trial_offer, cloud-only routes under/users/cloud_trial_offer): written by a superadmin at provisioning, consumed by{consumed: true}or by the portal's refusal;…/gois the one Windmill→portal hop that mints a portal login, over the sameCUSTOMER_SERVICE_TOKENtrust the onboarding hook uses (users_ee.rs, the portal's admin token). It never expires on its own. - OAuth login (
oauth2_ee.rslogin_externally, decision inexisting_login_decision) matches an existing account by lowercased email only. Same provider → login; apending_oauthaccount (seePENDING_OAUTH_LOGIN_TYPE) is claimed by the first login whose address the provider itself asserted and did not mark unverified —login_typebecomes the client key and the hash is nulled; otherwiserequire_preexisting_user_for_oauthdecides: on, every existing account is loggable-into by any provider; off, "exists but with a different login type". A new account getslogin_type = <client key>. - OAuth email trust:
LoginUserInfo.email_verifiedis read leniently (bool or "true"/"false" strings) and is only consulted for the claim above; only GitHub is filtered toprimary && verified; a missing email is fabricated fromnameas<name>@windmill.devand reacheslogin_externallywithemail_asserted = false. GET /api/oauth/login/{client}is an unauthenticated 302 to the provider — a plain link from any page starts SSO.CLOUD_HOSTEDis presence-tested (windmill-common/src/worker.rs):CLOUD_HOSTED=falsestill enables cloud mode. Of the routes above only the cloud trial offer and onboarding profile routes are cloud-gated; for the rest, cloud only adds quotas.CREATE_WORKSPACE_REQUIRE_SUPERADMINdefaults totruewhen unset; only the literal"true"enables it when set.