feat(server/cli): add IDP authentication via Device Authentication Flow#1152
feat(server/cli): add IDP authentication via Device Authentication Flow#1152rene-oromtz wants to merge 15 commits into
Conversation
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #1152 +/- ##
==========================================
+ Coverage 77.99% 78.29% +0.30%
==========================================
Files 118 120 +2
Lines 12401 12651 +250
Branches 1023 1040 +17
==========================================
+ Hits 9672 9905 +233
- Misses 2508 2520 +12
- Partials 221 226 +5
*This pull request uses carry forward flags. Click here to find out more.
🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Pull request overview
Adds OIDC Device Authorization Grant (“device flow”) support so the CLI can authenticate via an external IDP while the server proxies the device-code/token/userinfo exchanges and still issues Testflinger JWT access/refresh tokens.
Changes:
- Introduces new server-side
/oidc/auth-initand/oidc/auth-poll/<request_id>endpoints and supporting OIDC helper functions. - Refactors OIDC user registration from
web_clientsintoclient_permissions, and centralizes token issuance viaauth.issue_tokens(). - Extends the CLI login flow with
--method oidcto initiate device flow, open a browser, and poll until completion.
Reviewed changes
Copilot reviewed 22 out of 22 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
| server/tests/test_oidc.py | Updates OIDC web-login tests to validate registration in client_permissions. |
| server/tests/test_oidc_api.py | Adds unit tests for new /oidc device-flow endpoints and helper behaviors. |
| server/tests/conftest.py | Adds JWT_SIGNING_KEY to the OIDC test app environment. |
| server/src/testflinger/oidc/views.py | Switches web callback registration to register_oidc_client(userinfo). |
| server/src/testflinger/oidc/helpers.py | Adds helper utilities for OIDC metadata, POSTs with client auth, and userinfo retrieval. |
| server/src/testflinger/oidc/api.py | Implements /oidc/auth-init and /oidc/auth-poll/<request_id> device-flow endpoints. |
| server/src/testflinger/oidc/init.py | Allows OIDC public clients by making OIDC_CLIENT_SECRET optional. |
| server/src/testflinger/database.py | Adds device-code storage helpers, TTL index for device codes, and OIDC client upsert into client_permissions. |
| server/src/testflinger/application.py | Registers the new oidc_api blueprint under /oidc when OIDC is configured. |
| server/src/testflinger/api/v1.py | Reuses centralized auth.issue_tokens() and filters permissions for refresh-token flow. |
| server/src/testflinger/api/auth.py | Adds issue_tokens() helper and defines a shared permissions field allowlist + default refresh TTL. |
| server/schemas/openapi.json | Updates OpenAPI schema to include the new /oidc endpoints and tag. |
| server/docker-compose.yml | Adjusts local dev env for public OIDC client usage and updated issuer base URL. |
| server/devel/openapi_app.py | Registers /oidc blueprint unconditionally so it appears in generated schema. |
| server/devel/dex-config.yaml | Updates Dex dev configuration for issuer base URL and device-grant enablement. |
| server/charm/tests/unit/test_config.py | Updates charm config tests to reflect optional oidc_client_secret. |
| server/charm/src/config.py | Updates charm OIDC validation to not require oidc_client_secret. |
| cli/tests/test_cli_auth.py | Adds/updates CLI tests for credential login default and OIDC device-flow login. |
| cli/tests/conftest.py | Adds oidc_auth_fixture to mock /oidc/auth-init + /oidc/auth-poll flows. |
| cli/testflinger_cli/client.py | Extends HTTPError to carry headers/error_code; adds OIDC client methods. |
| cli/testflinger_cli/auth.py | Adds OIDC device-flow authentication path and JWT decoding improvements. |
| cli/testflinger_cli/init.py | Adds --method {credentials,oidc} to login and wires it into auth initialization. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
ajzobro
left a comment
There was a problem hiding this comment.
OK, quick overview of the application code, did not try to focus on the tests. Let's discuss the overall design first.
| or os.environ.get("TESTFLINGER_ERROR_THRESHOLD") | ||
| or consts.TESTFLINGER_ERROR_THRESHOLD | ||
| ) | ||
| auth_method = getattr(self.args, "method", "credentials") |
There was a problem hiding this comment.
Do we need this? Could the client have a service endpoint to query to know if authentication is required?
There was a problem hiding this comment.
sequenceDiagram
actor User
participant CLI as testflinger-cli
participant Server as testflinger-server
participant OIDC@{ "type": "control" } as OIDC
rect rgb(235, 245, 255)
Note over User,Server: Have client-id + secret-key: basic auth
User->>CLI: Run command with client-id + secret-key
CLI->>CLI: Validate both parameters are present
CLI->>Server: Request with Basic Auth<br/>base64(client-id:secret-key)
Server->>Server: Validate client credentials
Server-->>CLI: 200 OK / authorized response
CLI-->>User: Show result
end
There was a problem hiding this comment.
sequenceDiagram
actor User
participant CLI as testflinger-cli
participant Server as testflinger-server
participant OIDC@{ "type": "control" } as OIDC
rect rgb(235, 255, 235)
Note over User,Server: Neither client-id nor secret-key refresh token accepted auth as token user
User->>CLI: Run command without client-id + secret-key
CLI->>CLI: Load stored refresh/bearer token
CLI->>Server: Request with Bearer refresh token
Server->>Server: Validate token
Server-->>CLI: 200 OK / authenticated as token-associated user
CLI-->>User: Show result
end
There was a problem hiding this comment.
sequenceDiagram
actor User
participant CLI as testflinger-cli
participant Server as testflinger-server
participant OIDC@{ "type": "control" } as OIDC
rect rgb(255, 245, 235)
Note over User,OIDC: Neither client-id nor secret-key, token rejected, server uses OIDC
User->>CLI: Run command without client-id + secret-key
CLI->>CLI: Load stored refresh/bearer token
CLI->>Server: Request with Bearer refresh token
Server->>Server: Token expired or rejected
Server-->>OIDC: Initiate OIDC auth flow
OIDC-->>Server: Auth code
Server->>CLI: 401 Unauthorized, try this instead: Auth code
CLI->>CLI: Delete stored token
CLI->>User: Display url and code
User->>OIDC: Complete the handshake
CLI->>Server: Polling for auth completion
Server-->>OIDC: Is this user cool?
OIDC-->>Server: Yes + User identity (email)
Server->>CLI: Issue refresh token
User-->CLI: Potential user re-issue of command?
CLI->>Server: Retry request with new Bearer token
Server-->>CLI: 200 OK / authenticated response
CLI-->>User: Show result
end
There was a problem hiding this comment.
sequenceDiagram
actor User
participant CLI as testflinger-cli
participant Server as testflinger-server
participant OIDC@{ "type": "control" } as OIDC
rect rgb(255, 235, 235)
Note over User,Server: Neither client-id nor secret-key, token rejected, server does not use OIDC
User->>CLI: Run command without client-id + secret-key
CLI->>CLI: Load stored refresh/bearer token
CLI->>Server: Request with Bearer refresh token
Server->>Server: Token expired or rejected
Server-->>CLI: 401 Unauthorized, delete stored token
CLI->>CLI: Delete stored token
CLI-->>User: Authentication failed
end
There was a problem hiding this comment.
sequenceDiagram
actor User
participant CLI as testflinger-cli
participant Server as testflinger-server
participant OIDC@{ "type": "control" } as OIDC
rect rgb(245, 245, 245)
Note over User,Server: Neither client-id nor secret-key, no token, server does not require auth
User->>CLI: Run command without credentials or token
CLI->>CLI: No stored bearer/refresh token found
CLI->>Server: Request without Authorization header
Server->>Server: Auth not required
Server-->>CLI: 200 OK / anonymous user response
CLI-->>User: Show result
end
| from http import HTTPStatus | ||
|
|
||
| import requests | ||
| from apiflask import APIBlueprint, abort |
There was a problem hiding this comment.
flask and apiflask now both offer abort and we seem to be using different things in different places.
Should we be switching more or less over to apiflask and/or could we make flask work for our purposes and avoid clouding up the water?
There was a problem hiding this comment.
If we want a JSON response, we should use apiflask. I think we mostly are using apiflask from the API as this is easier to parse
| @@ -116,6 +116,7 @@ def oidc_app(oidc_client, mongo_app, iam_server, monkeypatch): | |||
| monkeypatch.setenv("OIDC_CLIENT_SECRET", oidc_client.client_secret) | |||
| monkeypatch.setenv("OIDC_PROVIDER_ISSUER", iam_server.url) | |||
| monkeypatch.setenv("WEB_SECRET_KEY", "my_web_secret_key") | |||
There was a problem hiding this comment.
I wonder what this is for...
There was a problem hiding this comment.
The WEB_SECRET_KEY? That is required so Flask app can sign the web session cookies
| client_entry = { | ||
| "openid_sub": "1234", | ||
| "email": user.emails[0], | ||
| "client_id": user.emails[0], |
There was a problem hiding this comment.
We should talk about the concept that the IDP might give back more than one email address and whether we can know that order and that that order will NEVER change.
What would the implications be if that list were to change?
There was a problem hiding this comment.
Okay, I was researching this, seems that the iam_server fixture is using the SCIM (System for Cross-domain Identity Management) schema that allows the multi-value emails.
The IDP provider on the other hand, needs to conform to the OIDC Spec and that one specifies the email to be a string which should be the user preferred email:
email string End-User's preferred e-mail address.
There was a problem hiding this comment.
My only concern then, would be if a user changes their preferred email address.
barbara@example.com -> barb@canonical.com
Doesn't matter because we are using the sub in the client permissions Here we are just setting the email (preferred email).
| or consts.TESTFLINGER_ERROR_THRESHOLD | ||
| ) | ||
| auth_method = getattr(self.args, "method", "credentials") | ||
|
|
There was a problem hiding this comment.
sequenceDiagram
actor User
participant CLI as testflinger-cli
participant Server as testflinger-server
participant OIDC@{ "type": "control" } as OIDC
rect rgb(245, 235, 255)
Note over User,OIDC: Neither client-id nor secret-key, no token, server uses OIDC
User->>CLI: Run command without credentials or token
CLI->>CLI: No stored bearer/refresh token found
CLI->>Server: Request without Authorization header
Server-->>OIDC: Initiate OIDC auth flow
OIDC-->>Server: Auth code
Server->>CLI: 401 Unauthorized, try this instead: Auth code
CLI->>User: Display URL and code
User->>OIDC: Complete the handshake
CLI->>Server: Poll for auth completion
Server-->>OIDC: Is this user cool?
OIDC-->>Server: Yes + user identity (email)
Server->>CLI: Issue refresh token
User-->>CLI: Potential user re-issue of command?
CLI->>Server: Retry request with new Bearer token
Server-->>CLI: 200 OK / authenticated response
CLI-->>User: Show result
end
|
@ajzobro thanks for your review! I modified the PR with the design decisions we agree on, please let me know if I capture those effectively! |
|
Service accounts ... need to switch to using service account credentials.
We can talk more about this in the C3 context offline.
…On Sat, Jun 20, 2026 at 3:23 AM Dio He ***@***.***> wrote:
***@***.**** commented on this pull request.
------------------------------
In server/src/testflinger/api/auth.py
<#1152 (comment)>
:
> +
+ Handles token generation, refresh token expiration, and OWASP logging.
+ Used by both credential-based and OIDC-based authentication flows.
+
+ :param client_id: Testflinger client ID for which to issue tokens
+ :param allowed_resources: Permissions dict to encode into the JWT
+ :param refresh_token_ttl: Optional expiration time for refresh token
+ :return: Dict with access_token, token_type, expires_in, refresh_token
+ """
+ secret_key = os.environ.get("JWT_SIGNING_KEY")
+ access_token = generate_access_token(allowed_resources, secret_key)
+
+ role = ServerRoles(allowed_resources.get("role", ServerRoles.CONTRIBUTOR))
+ refresh_expires_in = (
+ None
+ if role in (ServerRoles.ADMIN, ServerRoles.MANAGER)
Thanks for flagging this.
This will affect C3. We currently use a Testflinger admin token for
restricted-queue automation (used by QA, HWE, and PE), and we've been
relying on the refresh token not expiring so we don't need to handle token
renew.
If admin refresh tokens are going to expire as well, we'll need to figure
out what changes are needed on the C3 side.
—
Reply to this email directly, view it on GitHub
<#1152?email_source=notifications&email_token=BZUIHA7MO3UN6IGT6G2IDBT5AZCXZA5CNFSNUABKM5UWIORPF5TWS5BNNB2WEL2QOVWGYUTFOF2WK43UKJSXM2LFO4XTINJTGY3TMNJTHE32M4TFMFZW63VHNVSW45DJN5XKKZLWMVXHJLDGN5XXIZLSL5RWY2LDNM#discussion_r3445688313>,
or unsubscribe
<https://github.com/notifications/unsubscribe-auth/BZUIHAYYPGNFZPYWXCGWMTL5AZCXZAVCNFSNUABFKJSXA33TNF2G64TZHM2DQNBSGA2TCNZYHNEXG43VMU5TINRXG44TSNZVGE32C5QC>
.
Triage notifications, keep track of coding agent tasks and review pull
requests on the go with GitHub Mobile for iOS
<https://github.com/notifications/mobile/ios/BZUIHA47SXFJ4YULN3CSGH35AZCXZA5CNFSNUABKM5UWIORPF5TWS5BNNB2WEL2QOVWGYUTFOF2WK43UKJSXM2LFO4XTINJTGY3TMNJTHE32M4TFMFZW63VHNVSW45DJN5XKKZLWMVXHJKTGN5XXIZLSL5UW64Y>
and Android
<https://github.com/notifications/mobile/android/BZUIHA37BLQR2S2LY7GLESD5AZCXZA5CNFSNUABKM5UWIORPF5TWS5BNNB2WEL2QOVWGYUTFOF2WK43UKJSXM2LFO4XTINJTGY3TMNJTHE32M4TFMFZW63VHNVSW45DJN5XKKZLWMVXHJLTGN5XXIZLSL5QW4ZDSN5UWI>.
Download it today!
You are receiving this because you were mentioned.Message ID:
***@***.***>
|
Description
This PR adds the Device authentication Flow to be leveraged by the CLI.
If OIDC is configured server side (and if the provider supports the Device Flow) authentication with
--method oidcis now supported.This PR:
/oidcroute. With this endpoints, the Testflinger server can act as a proxy and redirect the request to the IDP provider.auth-init: returns a unique request ID to the user instead of thedevice-code(server holds this data for further validation) along with other relevant information for user login via browserauth-poll: uses therequest_idand the previously storeddevice-codeto forward the request to the IDP and validate if user is already authenticatedThis also removes the previous web_clients collections in favor of storing all user permissions on client_permissions collection.
Resolved issues
Resolves CERTTF-1196
Documentation
For review simplicity, I will add the documentation on IDP usage on a follow up PR
Web service API changes
This introduces a new route through
/oidc/<endpoints>instead of/v1/<endpoint> given OIDC is optionalTests
Tested locally:
idc-device-local.mp4
Tested on staging:
Validate with admin credentials:
Grant permissions:
Submit with extended reservation (8hrs):