conductor

CI task system
git clone git://git.finwo.net/app/conductor
Log | Files | Refs | README | LICENSE

auth.md (8026B)


      1 Authentication
      2 ==============
      3 
      4 The conductor has one way in and two providers behind it. Either it runs
      5 its own login page, or an external provider does. Never both: the discovery
      6 document can only name one, and whichever it names is the one that answers.
      7 
      8 The application never learns which it is talking to. It asks for the
      9 discovery document, runs an authorization code flow against whatever that
     10 names, and presents the resulting access token as a bearer credential.
     11 
     12 Discovery
     13 ---------
     14 
     15 ```
     16 GET /.well-known/openid-configuration
     17 ```
     18 
     19 Served in both modes, on the conductor's own origin. With a provider
     20 configured this is that provider's document, passed through. Without one it
     21 describes the conductor's own endpoints.
     22 
     23 One non-standard field is added either way:
     24 
     25 ```json
     26 { "issuer": "...", "authorization_endpoint": "...", "token_endpoint": "...",
     27   "client_id": "conductor", "scopes": "openid" }
     28 ```
     29 
     30 A discovery document carries no client id, and the application has to learn
     31 one somewhere. Putting it here saves a second request and a second thing to
     32 configure.
     33 
     34 The flow
     35 --------
     36 
     37 An ordinary authorization code flow with PKCE, which is the only kind the
     38 conductor accepts. `code_challenge_method` must be `S256`; there is no
     39 `plain` and no implicit grant.
     40 
     41 ```
     42 1. GET  /.well-known/openid-configuration
     43 2. redirect to authorization_endpoint with code_challenge
     44 3. the provider sends the browser back with ?code=
     45 4. POST token_endpoint with code_verifier
     46 5. Authorization: Bearer <access_token> from then on
     47 ```
     48 
     49 The redirect uri is the application's own origin and path, with no
     50 fragment. The fragment belongs to the router, so the code comes back on the
     51 query string instead.
     52 
     53 Tokens
     54 ------
     55 
     56 An access token lives in memory only and is short-lived, `auth.access_ttl`,
     57 900 seconds by default. It is a JWT either way: signed by the provider, or
     58 signed by the conductor with `auth.session_secret`.
     59 
     60 A refresh token is not a JWT. It is an opaque random value, stored only as
     61 a sha256 hash, and it is the session. It lives in local storage so a
     62 reload does not sign you out.
     63 
     64 The refresh token is not rotated. Rotation only catches a stolen token in
     65 the case where the victim then presents the superseded copy, and in
     66 exchange two tabs refreshing at once race into a spurious logout and a
     67 hand-revoked session cannot be told from a leaked one. Revocation is the
     68 control that matters here, and it is explicit.
     69 
     70 `auth.refresh_sliding` decides what a refresh does to the deadline. True,
     71 the default, moves it to `now + auth.refresh_ttl`, so a session lasts that
     72 long after it was last used. False leaves the original deadline alone, so a
     73 session ends that long after signing in whatever happens in between.
     74 
     75 Revoking
     76 --------
     77 
     78 ```
     79 GET    /api/v1/sessions
     80 DELETE /api/v1/sessions/:id
     81 POST   /auth/revoke
     82 ```
     83 
     84 Every live session is listed with the browser and address it was last seen
     85 from, so a person can tell them apart. Those two are recorded for that and
     86 nothing else. They are never compared and never validated: moving between
     87 networks is ordinary, and a browser that updates itself is not an intruder.
     88 
     89 An administrator sees every session, anyone else sees their own. Revoking
     90 one ends it at the next refresh, which is at most `auth.access_ttl` away.
     91 Disabling an account or changing its password revokes all of its sessions
     92 at once.
     93 
     94 With a provider configured none of this is populated. Nothing issues a
     95 refresh token, and a session is ended in the provider.
     96 
     97 The built-in provider
     98 ---------------------
     99 
    100 Used when no `auth.oidc.discovery_url` is set, or when
    101 `auth.local.enabled` is set true to override one.
    102 
    103 ```
    104 GET  /.well-known/openid-configuration
    105 GET  /auth/authorize      the login page
    106 POST /auth/authorize      credentials, redirects with a code
    107 POST /auth/token          authorization_code and refresh_token grants
    108 POST /auth/revoke
    109 GET  /auth/logout
    110 GET  /auth/jwks           an empty set
    111 ```
    112 
    113 The login page is a static file with a plain form. It has no action, so the
    114 authorization parameters stay in the query string where the page was asked
    115 for, and nothing has to be rendered into it. There is no inline script and
    116 no inline style, which is what lets a Content-Security-Policy ship with the
    117 application at all.
    118 
    119 Its key set is empty and honestly so. The tokens are HS256 and nothing
    120 outside this conductor verifies them; the endpoint exists for shape.
    121 
    122 An authorization code is a signed token that lives for 60 seconds. It
    123 carries the subject, the redirect uri, the code challenge and an id, so
    124 none of that needs storing. Redeeming one writes the session row under an
    125 id derived from the code's own id, so redeeming it twice collides on the
    126 primary key and is refused. Codes are single use without a table of spent
    127 codes.
    128 
    129 `auth.local.redirect_uris` is where the browser may be sent back to. It is
    130 a comma separated list of absolute URLs, compared by origin, and defaults
    131 to `server.public_url`. A redirect uri that is not listed is refused
    132 outright rather than redirected to, or this would be an open redirect. The
    133 refusal names what is registered, because `localhost` and `127.0.0.1` are
    134 different origins and that is otherwise a long afternoon.
    135 
    136 ```sh
    137 CONDUCTOR_LOCAL_REDIRECT_URIS=http://127.0.0.1:8080,http://localhost:8080
    138 ```
    139 
    140 An entry without a scheme is refused at startup rather than silently
    141 matching nothing. `*` allows any http or https origin, which is meant for
    142 local testing and should not be anywhere near a deployment.
    143 
    144 Using an external provider
    145 --------------------------
    146 
    147 Set the discovery url and the client id:
    148 
    149 ```yaml
    150 auth:
    151   oidc:
    152     discovery_url: https://idp.example.com/realms/ci/.well-known/openid-configuration
    153     client_id: conductor
    154     admin_role: conductor-admin
    155 ```
    156 
    157 There is no client secret. The application runs the flow itself as a public
    158 client, so the conductor never redeems a code and has nothing to
    159 authenticate with. A confidential client cannot be used here.
    160 
    161 In Keycloak that means:
    162 
    163 - the client is **public**, not confidential
    164 - **Standard flow** on, with **PKCE** required and `S256` as the method
    165 - the application's origin in **Web Origins**, or the browser cannot reach
    166   the token endpoint
    167 - the application's origin and path as a **Valid redirect URI**
    168 
    169 Roles are read from `realm_access.roles`, `resource_access[*].roles`, a
    170 flat `roles` claim and `groups`, because there is no standard. Anyone
    171 holding `auth.oidc.admin_role` is an administrator here.
    172 
    173 A provider account still gets a local row on first sight, because projects
    174 and worker tokens reference `users(id)`. Identity is the issuer and subject
    175 together, not the display name, so renaming someone at the provider does
    176 not produce a second account. That row can be disabled locally, which locks
    177 them out even though the provider would still admit them.
    178 
    179 The audience trap
    180 -----------------
    181 
    182 For a client named `conductor`, Keycloak issues an id_token with
    183 `aud: "conductor"` but an access token with `aud: ["account"]` and
    184 `azp: "conductor"`. The conductor checks `azp`, falling back to `aud`
    185 containing the client id. Checking only `aud` rejects every real access
    186 token, and the failure is a 401 on every request.
    187 
    188 This is worth knowing because it does not show up in testing unless the
    189 mock provider is made to reproduce it. `test/helpers/oidc.js` has a persona
    190 that does.
    191 
    192 Workers and triggers
    193 --------------------
    194 
    195 Neither goes through any of this.
    196 
    197 A worker token is 64 hex characters, presented as a bearer credential and
    198 stored as a sha256 hash. It never parses as a JWT, so the two meanings of
    199 the header cannot be confused. It reaches the worker protocol and nothing
    200 else.
    201 
    202 A project trigger secret is an HMAC over the exact request body, in
    203 `X-Hub-Signature-256`, or `X-Gitlab-Token` compared directly. It starts
    204 builds on its own project and reaches nothing else.
    205 
    206 Starting a build takes either that signature or a session that may manage
    207 the project. A project whose secret has been cleared is buildable only by
    208 its owner and by administrators, never anonymously.