Conversation
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
marked this pull request as draft
September 25, 2026 12:21
stuartc
added this pull request to stack #5208
September 25, 2026 12:22
Security Review ✅
|
Codecov Report❌ Patch coverage is 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. 🚀 New features to boost your workflow:
|
7 tasks
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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-serveris the RFC 8414 metadata document. Its URLs come from Lightning's configured URL, not the request's host.POST /api/oauth/tokentakesclient_credentialswith an RFC 7523 JWT assertion signed by the service account's key, and hands back a five-minute RFC 9068at+jwtaccess token. Scopes areusers:readandusers:write; asking for none gets both.jtigoes intoservice_account_assertions, whose primary key refuses a repeat on any node. An Oban job prunes old rows.LightningWeb.Plugs.AccessTokenAuthaccepts these access tokens and nothing else, withrequire_scope/2for controllers. It only accepts tokens issued to the service account registered right now, so changing or removingSERVICE_ACCOUNT_PUBLIC_KEYstops tokens already issued from working. Nothing uses it yet; the users API (CON-217) adds the pipeline and routes.Tokens.verify/1and the existing/apibearer auth are untouched on purpose. They get fixed in their own follow-ups.Closes CON-216
Validation steps
mix test test/lightning/service_account test/lightning_web/controllers/token_exchange_controller_test.exs test/lightning_web/plugs/access_token_auth_test.exsSERVICE_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 freshjti) and POST it form-encoded. Posting the same one again gets401 invalid_client.Additional notes for the reviewer
401 invalid_clientwith noerror_description, as RFC 6749 asks for failed client authentication. The reason is logged at info.alg: noneand for HS256 signed with the public key.token_signer. Theirtypandaudare what keep them apart from PATs and run tokens. Giving them their own key is a separate follow-up.jtitable has no Ecto schema; it's a composite primary key used throughinsert_all/delete_all.AI Usage
Pre-submission checklist