Skip to content

feat(server/cli): add IDP authentication via Device Authentication Flow#1152

Draft
rene-oromtz wants to merge 15 commits into
mainfrom
feat/cli-add-idp
Draft

feat(server/cli): add IDP authentication via Device Authentication Flow#1152
rene-oromtz wants to merge 15 commits into
mainfrom
feat/cli-add-idp

Conversation

@rene-oromtz

@rene-oromtz rene-oromtz commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

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 oidc is now supported.

This PR:

  1. Adds two need server endpoints under /oidc route. With this endpoints, the Testflinger server can act as a proxy and redirect the request to the IDP provider.
  2. auth-init: returns a unique request ID to the user instead of the device-code (server holds this data for further validation) along with other relevant information for user login via browser
  3. auth-poll: uses the request_id and the previously stored device-code to forward the request to the IDP and validate if user is already authenticated
  4. Upon successful authentication, user is issued Testflinger access and refresh tokens (NOT IDP tokens)
  5. Refresh token is stored so it can be reused until expiration (6 days)

This 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 optional

Tests

Tested locally:

idc-device-local.mp4

Tested on staging:

uv run testflinger-cli --server $SERVER login --method oidc
Please visit https://xxxxxxx/oauth2/device/verify and enter code xxxxxx to login.
Successfully authenticated as 'xxxxxxx@canonical.com'

Validate with admin credentials:

testflinger-cli --server $SERVER admin get client-permissions --testflinger-client-id 'xxxxxxx@canonical.com'
{"client_id": "xxxxxxx@canonical.com", "role": "contributor"}

Grant permissions:

testflinger-cli --server $SERVER admin set client-permissions --testflinger-client-id 'xxxxxxx@canonical.com' --max-reservation '{"audino": 36000}'
Updated permissions for client 'xxxxxxx@canonical.com'

Submit with extended reservation (8hrs):

uv run testflinger-cli --server $SERVER submit -p ~/Desktop/Testflinger/staging.yaml 
Job submitted successfully!
job_id: 53b9087f-1a60-4291-a12b-406923cb004f
This job will be picked up after the current job is complete (it is next in line)
...
Current time:           [2026-06-17T19:19:47.906408+00:00]
Reservation expires at: [2026-06-18T03:19:47.906458+00:00]
Reservation will automatically timeout in 28800 seconds

@codecov

codecov Bot commented Jun 16, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.66667% with 19 lines in your changes missing coverage. Please review.
✅ Project coverage is 78.29%. Comparing base (2beb36b) to head (17c1249).
⚠️ Report is 6 commits behind head on main.

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     
Flag Coverage Δ *Carryforward flag
agent 75.78% <ø> (ø) Carriedforward from a9bfa52
cli 92.04% <96.66%> (+0.18%) ⬆️
device 64.11% <ø> (ø) Carriedforward from a9bfa52
server 88.97% <90.66%> (-0.03%) ⬇️

*This pull request uses carry forward flags. Click here to find out more.

Components Coverage Δ
Agent 75.78% <ø> (ø)
CLI 92.04% <96.66%> (+0.18%) ⬆️
Common ∅ <ø> (∅)
Device Connectors 64.11% <ø> (ø)
Server 88.97% <90.66%> (-0.03%) ⬇️
🚀 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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-init and /oidc/auth-poll/<request_id> endpoints and supporting OIDC helper functions.
  • Refactors OIDC user registration from web_clients into client_permissions, and centralizes token issuance via auth.issue_tokens().
  • Extends the CLI login flow with --method oidc to 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.

Comment thread server/tests/test_oidc_api.py
Comment thread server/tests/test_oidc_api.py Outdated
Comment thread server/tests/test_oidc.py Outdated
Comment thread cli/tests/test_cli_auth.py Outdated
Comment thread cli/tests/test_cli_auth.py Outdated
Comment thread server/devel/dex-config.yaml
Comment thread server/src/testflinger/database.py
Comment thread server/src/testflinger/api/auth.py
Comment thread server/charm/src/config.py
Comment thread cli/testflinger_cli/auth.py Outdated
@rene-oromtz rene-oromtz changed the title feat(server/cli): add IDP authentication via Device Flow feat(server/cli): add IDP authentication via Device Authentication Flow Jun 17, 2026

@ajzobro ajzobro left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, quick overview of the application code, did not try to focus on the tests. Let's discuss the overall design first.

Comment thread cli/testflinger_cli/__init__.py Outdated
or os.environ.get("TESTFLINGER_ERROR_THRESHOLD")
or consts.TESTFLINGER_ERROR_THRESHOLD
)
auth_method = getattr(self.args, "method", "credentials")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need this? Could the client have a service endpoint to query to know if authentication is required?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Loading

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Loading

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Loading

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Loading

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Loading

Comment thread cli/testflinger_cli/auth.py Outdated
Comment thread cli/testflinger_cli/auth.py Outdated
Comment thread cli/testflinger_cli/auth.py Outdated
Comment thread cli/testflinger_cli/client.py
from http import HTTPStatus

import requests
from apiflask import APIBlueprint, abort

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread server/tests/conftest.py
@@ -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")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder what this is for...

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The WEB_SECRET_KEY? That is required so Flask app can sign the web session cookies

Comment thread server/tests/test_oidc.py
client_entry = {
"openid_sub": "1234",
"email": user.emails[0],
"client_id": user.emails[0],

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Comment thread server/tests/test_oidc_api.py Outdated
Comment thread server/docker-compose.yml
or consts.TESTFLINGER_ERROR_THRESHOLD
)
auth_method = getattr(self.args, "method", "credentials")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Loading

@rene-oromtz rene-oromtz requested a review from ajzobro June 18, 2026 22:22
@rene-oromtz

rene-oromtz commented Jun 18, 2026

Copy link
Copy Markdown
Contributor Author

@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!

@ajzobro

ajzobro commented Jun 22, 2026 via email

Copy link
Copy Markdown
Collaborator

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants