Skip to content

providers/oauth2: require client_secret on device_code exchange for confidential clients#21700

Open
SAY-5 wants to merge 1 commit intogoauthentik:mainfrom
SAY-5:fix/device-code-client-secret
Open

providers/oauth2: require client_secret on device_code exchange for confidential clients#21700
SAY-5 wants to merge 1 commit intogoauthentik:mainfrom
SAY-5:fix/device-code-client-secret

Conversation

@SAY-5
Copy link
Copy Markdown

@SAY-5 SAY-5 commented Apr 19, 2026

What

TokenParams.__post_init__ in authentik/providers/oauth2/views/token.py only ran the client_secret check for the authorization_code and refresh_token grant types:

if self.grant_type in [GRANT_TYPE_AUTHORIZATION_CODE, GRANT_TYPE_REFRESH_TOKEN]:
    if self.provider.client_type == ClientTypes.CONFIDENTIAL and not compare_digest(
        self.provider.client_secret, self.client_secret,
    ):
        raise TokenError("invalid_client")

The device-code path (__post_init_device_code) then looked up the DeviceToken purely by device_code and issued an access token whenever one matched — regardless of whether the caller had proved ownership of the confidential client:

def __post_init_device_code(self, request: HttpRequest):
    device_code = request.POST.get("device_code", "")
    code = DeviceToken.objects.filter(device_code=device_code, provider=self.provider).first()
    if not code:
        raise TokenError("invalid_grant")
    self.device_code = code

Per #20828 this is exploitable via the standard device-flow phishing shape (attacker starts device authorization, sends user_code to a victim, victim completes authorization, attacker redeems device_code): an attacker that only knows client_id can redeem a stolen device_code without ever proving ownership of the confidential client.

RFC 6749 §2.3.1 requires confidential clients to authenticate to the token endpoint; RFC 8628 §3.4 inherits that. The device_code is bearer-shaped but is not a substitute for client credentials. Keycloak and Okta both enforce client_secret on the device token exchange for confidential clients.

Fix

Add GRANT_TYPE_DEVICE_CODE to the list that already runs the compare_digest check:

if self.grant_type in [
    GRANT_TYPE_AUTHORIZATION_CODE,
    GRANT_TYPE_REFRESH_TOKEN,
    GRANT_TYPE_DEVICE_CODE,
]:
    if self.provider.client_type == ClientTypes.CONFIDENTIAL and not compare_digest(
        self.provider.client_secret, self.client_secret,
    ):
        raise TokenError("invalid_client")
  • Public clients are unaffected (guard is gated on ClientTypes.CONFIDENTIAL).
  • client_credentials / password keep their own client-auth path in __post_init_client_credentials (which also enforces the secret, plus supports client assertion).
  • authorization_code / refresh_token paths are unchanged.

Test plan

  • Repro on main: configure an OIDC provider with client_type: confidential, run a device flow, POST /application/o/token/ with only client_id=...&device_code=...&grant_type=urn:ietf:params:oauth:grant-type:device_code — gets an access token.
  • With this patch applied: same request returns {"error":"invalid_client"}; the request with the correct client_secret (or a valid Authorization: Basic ...) still succeeds.
  • Public clients: same request without client_secret still succeeds (unchanged behaviour).

Fixes #20828

…onfidential clients

TokenParams.__post_init__ only ran the client_secret check for the
authorization_code and refresh_token grant types:

	if self.grant_type in [GRANT_TYPE_AUTHORIZATION_CODE, GRANT_TYPE_REFRESH_TOKEN]:
		if self.provider.client_type == ClientTypes.CONFIDENTIAL and not compare_digest(
			self.provider.client_secret, self.client_secret,
		):
			raise TokenError("invalid_client")

The device_code path (__post_init_device_code) then looked up the
DeviceToken solely by device_code and issued an access token if one
matched. A caller that knows the client_id and has stolen a
device_code (e.g. via the standard phishing flow: attacker starts
device authorization, sends user_code to a victim, victim completes
authorization, attacker redeems the device_code) did not have to
prove ownership of the confidential client.

RFC 6749 Section 2.3.1 requires confidential clients to authenticate
to the token endpoint, and RFC 8628 Section 3.4 inherits that: the
device_code is bearer-shaped but not a substitute for client
credentials. Keycloak and Okta both enforce client_secret on the
device token exchange for confidential clients; we didn't.

Add GRANT_TYPE_DEVICE_CODE to the list so the existing compare_digest
check runs for it too. Public clients are unaffected (the guard is
gated on ClientTypes.CONFIDENTIAL). client_credentials/password keep
their own client-auth path in __post_init_client_credentials, which
also enforces the secret (and supports client assertion).

Fixes goauthentik#20828

Signed-off-by: SAY-5 <SAY-5@users.noreply.github.com>
@SAY-5 SAY-5 requested a review from a team as a code owner April 19, 2026 22:41
@netlify
Copy link
Copy Markdown

netlify bot commented Apr 19, 2026

Deploy Preview for authentik-docs ready!

Name Link
🔨 Latest commit 38251b9
🔍 Latest deploy log https://app.netlify.com/projects/authentik-docs/deploys/69e55a291f0b4c00082eeefd
😎 Deploy Preview https://deploy-preview-21700--authentik-docs.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

OIDC Device doesn't verifry client_secret for 'confidential' client type

1 participant