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.