Skip to content

Let service accounts sign in with OAuth 2.0 - #5207

Draft
stuartc wants to merge 4 commits into
stuart/con-215from
stuart/con-216
Draft

stuartc wants to merge 4 commits into
stuart/con-215from
stuart/con-216

Conversation

@stuartc

@stuartc stuartc commented Sep 25, 2026 •

Copy link
Copy Markdown
Member

Description

Lets the service account from #5206 sign in with plain OAuth 2.0, so jig (or any OAuth library) can get a short-lived token for the API. Stacks on #5206; review that one first.

  • GET /.well-known/oauth-authorization-server is the RFC 8414 metadata document. Its URLs come from Lightning's configured URL, not the request's host.
  • POST /api/oauth/token takes client_credentials with an RFC 7523 JWT assertion signed by the service account's key, and hands back a five-minute RFC 9068 at+jwt access token. Scopes are users:read and users:write; asking for none gets both.
  • Each assertion works once: its jti goes into service_account_assertions, whose primary key refuses a repeat on any node. An Oban job prunes old rows.
  • LightningWeb.Plugs.AccessTokenAuth accepts these access tokens and nothing else, with require_scope/2 for controllers. It only accepts tokens issued to the service account registered right now, so changing or removing SERVICE_ACCOUNT_PUBLIC_KEY stops tokens already issued from working. Nothing uses it yet; the users API (CON-217) adds the pipeline and routes.

Tokens.verify/1 and the existing /api bearer auth are untouched on purpose. They get fixed in their own follow-ups.

Closes CON-216

Validation steps

  1. mix test test/lightning/service_account test/lightning_web/controllers/token_exchange_controller_test.exs test/lightning_web/plugs/access_token_auth_test.exs
  2. By hand: set SERVICE_ACCOUNT_PUBLIC_KEY, fetch the metadata, sign an RS256 assertion (iss = sub = the key's RFC 7638 thumbprint, aud = token_endpoint, exp ≤ iat + 60, a fresh jti) and POST it form-encoded. Posting the same one again gets 401 invalid_client.

Additional notes for the reviewer

  1. Every assertion failure is 401 invalid_client with no error_description, as RFC 6749 asks for failed client authentication. The reason is logged at info.
  2. Only RS256 is accepted. There are tests for alg: none and for HS256 signed with the public key.
  3. Access tokens use the existing token_signer. Their typ and aud are what keep them apart from PATs and run tokens. Giving them their own key is a separate follow-up.
  4. The jti table has no Ecto schema; it's a composite primary key used through insert_all/delete_all.
  5. Using an access token never touches the database. The table is only written at the token endpoint.

AI Usage

  • I have used Claude Code
  • I have used another model
  • I have not used AI

Pre-submission checklist

  • I have performed an AI review of my code
  • I have implemented and tested all related authorization policies. (n/a: no project-scoped data yet; scopes arrive with CON-217's routes)
  • I have updated the changelog.
  • I have ticked a box in "AI usage" in this PR

jig needs to write into Lightning as the service account CON-215
registers, and any OAuth 2.0 library should be able to do the same. So
the exchange is plain RFC 6749 client credentials, with the caller
authenticating by an RFC 7523 JWT assertion signed with its private key.

GET /.well-known/oauth-authorization-server is the RFC 8414 metadata
document. Its token_endpoint is built from Lightning's configured URL,
not the request's host, because callers use that exact string as the
assertion's aud. POST /api/oauth/token sits in a scope of its own: its
body is form-encoded, and the :api pipeline takes only JSON. It reads
only the body, never the query string, so an assertion can't end up in
a proxy's access log, and prod's filter_parameters hides it from
Phoenix's own request logging.

An assertion is accepted once. Its jti goes into
service_account_assertions, whose primary key refuses a repeat on any
node, since Lightning runs as a cluster and its caches are per node. The
jti is recorded only after the signature and claims check out, so a
forged assertion can't burn a real one's jti, and a jti over 255 bytes
or holding a NUL is refused before it can reach the insert. An Oban job
deletes rows every ten minutes, five minutes after their assertion
expired, so a node whose clock lags the pruner's still refuses a replay.

Every assertion failure is a 401 invalid_client, as RFC 6749 asks for
failed client authentication, with no error_description; the reason goes
to the log at info. Only RS256 is accepted, so alg none and HS256 signed
with the public key both fail on the signature.

The access token is an RFC 9068 at+jwt lasting five minutes, signed with
the existing token signer. Nothing about it is stored. Its scopes are
users:read and users:write, and asking for none gets both.
Access tokens must not be accepted anywhere another token is, and no
other token may pass as one. Personal access tokens and run tokens are
signed with the same key, carry no aud, and have only Joken's generic
JWT typ, while Tokens.verify/1 picks a token's kind by its sub before
checking the signature. So these tokens get a plug of their own rather
than a new branch there.

AccessTokenAuth accepts a token only when its typ is at+jwt and its
signature, iss, aud and exp check out, and assigns its claims.
require_scope/2 is how a controller names the scope its actions need.
Refusals are RFC 6750 invalid_token or insufficient_scope, with a
WWW-Authenticate header.

The existing /api routes already refuse an access token, because no row
in user_tokens holds it; a test pins that. No route uses the plug yet;
the users API adds the pipeline and the first routes.
@stuartc
stuartc marked this pull request as draft September 25, 2026 12:21
@stuartc
stuartc added this pull request to stack #5208 September 25, 2026 12:22
@github-actions

Copy link
Copy Markdown

Security Review ✅

  • S0 (project scoping): N/A — this PR only adds OAuth token-exchange infrastructure and a service-account auth plug; no new endpoints read or mutate project-scoped resources (dataclips, runs, workflows, credentials, etc.), and the new service_account_assertions table stores JTI replay records with no project data.
  • S1 (authorization): N/A — no create/read/update/delete actions on project-scoped resources are added; LightningWeb.Plugs.AccessTokenAuth (lib/lightning_web/plugs/access_token_auth.ex:16) is not yet wired to any controller, and the existing :authenticated_api pipeline still rejects the new at+jwt tokens because Lightning.Tokens.verify/1 (lib/lightning/tokens.ex:100) only accepts user:/run: subjects — a fact locked in by test/lightning_web/plugs/access_token_auth_test.exs:141.
  • S2 (audit trail): N/A — no config-resource writes are introduced; the sole DB write is Repo.insert_all on service_account_assertions for replay prevention (lib/lightning/service_account/assertion.ex:100), which is a stateless auth artifact, not configuration.

@codecov

codecov Bot commented Sep 25, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.68085% with 5 lines in your changes missing coverage. Please review.
✅ Project coverage is 91.2%. Comparing base (e8d8d93) to head (7ac3a0a).

Files with missing lines Patch % Lines
...tning_web/controllers/token_exchange_controller.ex 93.1% 2 Missing ⚠️
lib/lightning/config.ex 50.0% 1 Missing ⚠️
lib/lightning/service_account/access_token.ex 94.4% 1 Missing ⚠️
lib/lightning/service_account/assertion.ex 97.1% 1 Missing ⚠️
Additional details and impacted files
@@               Coverage Diff                @@
##           stuart/con-215   #5207     +/-   ##
================================================
- Coverage            91.2%   91.2%   -0.0%     
================================================
  Files                 454     458      +4     
  Lines               22867   22961     +94     
================================================
+ Hits                20859   20944     +85     
- Misses               2008    2017      +9     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

An access token stayed good for its five minutes even after
SERVICE_ACCOUNT_PUBLIC_KEY was changed or removed, so taking the key away
didn't cut the service account off. The plug now checks the token's sub
against the service account registered now, and assigns that account so
controllers can name it as the actor on audit events.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: New Issues

Development

Successfully merging this pull request may close these issues.

1 participant