Implement PKCE under an FF

What does this MR do?

Implements PKCE under an FF.

The verifier is created and stored on the origin site. We limit its validity to 10 minutes, same as Doorkeeper's authorization_code_expires_in default.

  • Hop 1 (browser, origin to proxy): with the FF on, adds code_verifier to the origin-domain session (alongside state etc.), which is hashed as code_challenge and added to the proxy URL.
  • Hop 2 (browser, proxy to auth): when present, adds code_challenge onto the authorize URL, and records pkce in the proxy-domain session alongside proxy_auth_domain (... which contains the origin host). If there's no challenge present, deletes any pkce from the session.
  • Hop 3 (browser, auth to proxy): GitLab redirects to the proxy redirect_uri with code and state.
  • Hop 4 (browser, proxy to origin): when pkce is present in the proxy- domain session, adds pkce=1 to the origin redirect and deletes it from the session.
  • Hop 5 (server-side POST, origin to auth): when pkce=1 was received and the origin-domain session has code_verifier, adds code_verifier onto the token request body.

The FF is called FF_ENABLE_PKCE. It controls whether hop 1 generates a code_verifier for the origin-domain session, and whether auth state staleness is evaluated at all.

The pkce session variable and query parameter ensure that hop 5 only sends the verifier when hop 2 sent the challenge along. Without it, if we were mid-rollout or revert, and e.g. only hop 2 didn't have the PKCE code, hop 5 would see code_verifier in the origin-domain session and send it, resulting in a 503.

It is part of what gets us FF toggle safety, by having nothing but auth state refresh logic at hop 1 need to consult the FF; every hop after that works entirely with the data they receive and store, without consulting the FF again and thus raising the possibility of different FF states being read. It does mean, however, that an origin-domain session with a verifier stored will continue to use PKCE even with the FF disabled, until the session is reset.

Related to https://gitlab.com/gitlab-org/gitlab-pages/-/work_items/1183

Steps to verify locally

Here be dragons!

AI-generated text follows.

Verification steps

Setup

  1. GDK with Pages access control enabled.

  2. Build this branch: in gdk/gitlab-pages, git fetch origin kiv/implement-pkce-under-an-ff && git checkout FETCH_HEAD && make. Regenerating GDK's Pages config checks out the pinned version again.

  3. A project with a deployed Pages site, with Pages visibility "Only project members". Below, <site> is its URL and <pages> is the Pages host (e.g. pages.127.0.0.1.nip.io:3010).

  4. Toggle the FF in gdk.yml, then gdk restart gitlab-pages:

    env:
      FF_ENABLE_PKCE: 'true'
  5. Use a new private window per test, with devtools' Network tab open and "Preserve log" on. Watch gdk tail gitlab-pages | grep 'Authentication was successful'.

Opening <site> in a new private window stops at GitLab's sign-in page; that's the pause point below.

1. FF off: open <site>, sign in.

  • No code_challenge on <pages>/auth?… or /oauth/authorize?…; no pkce=1 on <site>/auth?….
  • Site loads; log has "pkce":false.

2. FF on: open <site>, sign in.

  • <pages>/auth?…&code_challenge=X → /oauth/authorize?…&code_challenge=X&code_challenge_method=S256 → … → <site>/auth?code=…&pkce=1&state=….
  • Site loads; log has "pkce":true.
  • OauthAccessGrant.last.code_challenge is X.

3. FF turned off mid-flow: FF on, open <site>, stop at sign-in. Turn FF off, restart Pages, sign in.

  • pkce=1 present; site loads; "pkce":true.

4. FF turned on mid-flow: FF off, open <site>, stop at sign-in. Turn FF on, restart Pages, sign in.

  • No pkce=1; site loads; "pkce":false.

5. GitLab enforces the challenge: FF on, open <site>, stop at sign-in. Copy the /oauth/authorize?… URL from the Network log, change code_challenge to any other 43-character value, load it, sign in.

  • <site>/auth?… returns 503; log has fetching access token failed.

6. 10-minute TTL: also set FF_ENABLE_PROJECT_PREFIX_COOKIE_PATH: 'false'; by default the pre-auth cookie has Path=/auth, so every visit to <site> starts with a fresh state and verifier. The cookie itself expires 10 minutes after the last visit (auth-cookie-session-timeout), so it has to be kept alive while waiting. FF on, open <site>, stop at sign-in. Note state and code_challenge on <pages>/auth?…, and copy that URL.

  • Open <site> again at about 4 and 8 minutes: same state and code_challenge each time.
  • At 11 minutes, open <site> again: new state and code_challenge.
  • Load the copied URL, sign in: <site>/auth?… returns 401; log has Authentication state did not match expected.
  • Open <site> again: the new state and code_challenge from before; site loads; "pkce":true.

Namespace in path

GDK has no setting for this, so edit the generated config by hand:

  1. gitlab/config/gitlab.yml, under pages:: namespace_in_path: true.
  2. gitlab-pages/gitlab-pages.conf: add namespace-in-path=true, and change auth-redirect-uri to http://<pages>/projects/auth.
  3. Add http://<pages>/projects/auth to the OAuth application's redirect URIs.
  4. gdk restart.

<site> is now http://<pages>/<namespace>/<project> (or http://<pages>/<unique-domain> with unique domains), as shown under Deploy > Pages. <namespace> below is its first path segment.

Tests 1–6 apply unchanged, except for the URLs:

  • <pages>/auth?… is <pages>/projects/auth?domain=http://<pages>/<namespace>&….
  • <site>/auth?… is <pages>/<namespace>/auth?….

In test 6, the pre-auth cookie has Path=/<namespace>/auth by default, and Path=/<namespace> with FF_ENABLE_PROJECT_PREFIX_COOKIE_PATH: 'false'.

TODO

Edited by Asherah Connor

Merge request reports

Loading
Loading