Skip to content
Merged
61 changes: 61 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
name: CI

on:
push:
branches:
- main
pull_request:
branches:
- main

jobs:
test:
name: Run Tests
runs-on: ubuntu-latest
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Set up JDK 21
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
cache: 'maven'

# Setup Maven with OAuth token
- name: Setup Maven with OAuth
uses: curityio/curity-maven-gh-action@v1
with:
client-secret: ${{ secrets.CURITY_CLI_CLIENT_SECRET }}

- name: Run tests
run: mvn verify

package:
name: Build Package
runs-on: ubuntu-latest
if: github.event_name == 'push' && github.ref == 'refs/heads/main'

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Set up JDK 21
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
cache: 'maven'

# Setup Maven with OAuth token
- name: Setup Maven with OAuth
uses: curityio/curity-maven-gh-action@v1
with:
client-secret: ${{ secrets.CURITY_CLI_CLIENT_SECRET }}

- name: Build package
run: mvn package

34 changes: 34 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Changelog

All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

## [1.0.1] - 2025-11-05

### Security

- Enforce stricter OAuth client verification - only confidential, authenticated clients are now allowed to perform
authorization.

## [1.0.0] - 2025-11-05

### Added

- Initial commit.

### Technical Details

- Built with Kotlin.
- Requires Java 21 or newer
- Compatible with Curity Identity Server 10.4.2
- Uses jose4j for JWT validation
- Jakarta Validation API 3.0.0 for configuration validation

[Unreleased]: https://github.com/curityio/access-token-authenticator/compare/v1.0.0...HEAD

[1.0.0]: https://github.com/curityio/access-token-authenticator/releases/tag/v1.0.0

19 changes: 14 additions & 5 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,12 @@ AccessTokenAuthenticator Authenticator Plug-in

A custom Authenticator plugin for the Curity Identity Server.

This plugin allows users to authenticate using HAAPI by first obtaining an access token via other means.
.. warning::
Copy link
Member

Choose a reason for hiding this comment

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

I think you can use a separate header for this:

Warning
~~~~~~

This plugin...

You can use different level of header if you don't want it to be H2.

This plugin cannot be used by users to authenticate directly from a browser.
Copy link
Member

Choose a reason for hiding this comment

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

Maybe:

You can use this plugin with the Hypermedia Authentication API (`HAAPI`_). If a user tries to run an authentication flow with this authenticator from a browser, they will see an error.

Only confidential clients with the HAAPI capability are able to use this authenticator in an authentication flow.

Only authentication via `HAAPI`_ is allowed and the OAuth client initiating authorization MUST be
a confidential client with the HAAPI capability.

This plugin allows users to authenticate using `HAAPI`_ by first obtaining an access token via other means.

That allows a form of token exchange where the end user may be prompted to consent to upscoping, for example.

Expand All @@ -20,15 +25,19 @@ The following configuration settings are available:
* ``required-scopes`` - required token scopes. Optional.
* ``required-purpose`` - required token ``purpose``. Default: ``access_token``. If set to a blank string, this will be ignored.
* ``subject-claim-name`` - the name of the subject claim. Default: ``sub``.
* ``allowed-oauth-client-ids`` - the allowed OAuth clients. If empty, any confidential HAAPI client will be allowed.
* ``key-verification/id`` - ID of an existing token signature verification key.

.. note::
Even if an OAuth client is allowed by the ``allowed-oauth-client-ids`` setting, it will NOT be allowed to perform authorization
Copy link
Member

Choose a reason for hiding this comment

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

I would rewrite slightly:

OAuth clients added to the ``allowed-oauth-client-ids`` list must be confidential clients. Public clients will be rejected when trying to use this authenticator, even if they are on the list. This is to ensure that only a limited set of OAuth clients, ones that the authorization server trusts, will have the power to obtain sensitive tokens on behalf of end users.

This limitation applies only to the client that runs an authentication flow with this authenticator. The access token used as the input to this authenticator can be obtained by any OAuth client, even a public one.

unless it is a confidential, authenticated client. This is to ensure that only a limited set of OAuth clients
that can be trusted will have the power to obtain sensitive tokens on behalf of end users.
This applies only to the OAuth client performing the authorization flow within which this authenticator
will be called, not to the OAuth client that obtained the presented access token.

.. image:: docs/images/access_token_config.png
:alt: Access Token Authenticator Configuration

.. note::
This plugin should not be used by users to authenticate using a browser because it is a bad security practice to expose
access tokens directly to end users. Use `HAAPI`_ instead.

Building the Plugin
~~~~~~~~~~~~~~~~~~~

Expand Down
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

<groupId>io.curity.identityserver.plugin</groupId>
<artifactId>identityserver.plugins.authenticators.access_token</artifactId>
<version>1.0.0</version>
<version>1.0.1</version>
<packaging>jar</packaging>

<name>Curity AccessToken Authenticator</name>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,13 @@ import org.jose4j.jwt.consumer.InvalidJwtException
import org.jose4j.jwt.consumer.InvalidJwtSignatureException
import org.jose4j.jwt.consumer.JwtConsumerBuilder
import org.slf4j.LoggerFactory
import se.curity.identityserver.sdk.Nullable
import se.curity.identityserver.sdk.authentication.AuthenticationResult
import se.curity.identityserver.sdk.authentication.AuthenticatorRequestHandler
import se.curity.identityserver.sdk.errors.ErrorCode
import se.curity.identityserver.sdk.haapi.ProblemContract
import se.curity.identityserver.sdk.http.MediaType
import se.curity.identityserver.sdk.oauth.OAuthClient
import se.curity.identityserver.sdk.service.ExceptionFactory
import se.curity.identityserver.sdk.web.Request
import se.curity.identityserver.sdk.web.Response
Expand Down Expand Up @@ -72,9 +75,48 @@ class AccessTokenAuthenticatorRequestHandler(
override fun preProcess(
request: Request,
response: Response,
): AccessTokenAuthenticatorRequestModel =
if (request.isGetRequest) AccessTokenAuthenticatorRequestModel.forGet()
): AccessTokenAuthenticatorRequestModel {
enforceHaapiFlow(request, response)
checkIfOAuthClientIsAllowed(response)
return if (request.isGetRequest) AccessTokenAuthenticatorRequestModel.forGet()
else AccessTokenAuthenticatorRequestModel.forPost(request)
}

private fun enforceHaapiFlow(request: Request, response: Response) {
if (request.acceptableMediaTypes != MediaType.HAAPI_JSON.toString()) {
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I don't think we expect any HAAPI client to send any "complex" Accept header, so I guess it's ok to enforce the header has the exact value we want.

failAuthentication(
response, "Request must accept only the Media-Type ${MediaType.HAAPI_JSON} to call this endpoint",
)
}
}

private fun checkIfOAuthClientIsAllowed(response: Response) {
val clientNotAllowed = "OAuth client is not allowed"

val oauthClient: @Nullable OAuthClient = _config.requestingOAuthClient.client
?: failAuthentication(
response, clientNotAllowed,
detailedMessage = "The authorization flow was not started by a known OAuth Client, cannot proceed."
)

if (oauthClient.isPublic) {
failAuthentication(
response, clientNotAllowed,
detailedMessage = "The authorization flow was started by a public OAuth Client, cannot proceed."
)
}

val allowedClients = _config.allowedOauthClientIds

if (allowedClients.isNotEmpty() && !allowedClients.contains(oauthClient.id)) {
val allowedClientsText = allowedClients.joinToString(", ")
failAuthentication(
response,
clientNotAllowed,
detailedMessage = "OAuth client is not allowed, allowed clients are: $allowedClientsText"

Choose a reason for hiding this comment

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

Will allowedClientsText only be visible if detailed message are disabled in the profile? We should not reveal client IDs for other clients in prod.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yes, exactly. That's why I put an explict detailedMessage parameter there.
Also, this is tested in the main repo.

)
}
}

override fun get(
requestModel: AccessTokenAuthenticatorRequestModel,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import se.curity.identityserver.sdk.config.annotation.Description
import se.curity.identityserver.sdk.config.annotation.SizeConstraint
import se.curity.identityserver.sdk.haapi.RepresentationFunction
import se.curity.identityserver.sdk.plugin.descriptor.AuthenticatorPluginDescriptor
import se.curity.identityserver.sdk.service.RequestingOAuthClient
import se.curity.identityserver.sdk.service.crypto.AsymmetricSignatureVerificationCryptoStore
import java.util.Optional

Expand All @@ -33,11 +34,11 @@ object AccessTokenAuthenticatorConstants {
* Plugin configuration object.
*/
interface AccessTokenAuthenticatorConfig : Configuration {
@get:Description("The expected token issuer")
@get:Description("The expected token issuer.")
@get:SizeConstraint(min = 2, max = 1024)
val requiredIssuer: String

@get:Description("The expected token audience")
@get:Description("The expected token audience.")
val requiredAudience: Optional<@SizeConstraint(min = 2, max = 128) String>

@get:Description("The required scopes, if any.")
Expand All @@ -56,8 +57,18 @@ interface AccessTokenAuthenticatorConfig : Configuration {
@get:SizeConstraint(min = 1, max = 64)
val subjectClaimName: String

@get:Description("The IDs of the allowed OAuth clients. If empty, any confidential OAuth client will be allowed.")
val allowedOauthClientIds: List<@SizeConstraint(min = 1, max = 128) String>

Choose a reason for hiding this comment

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

This @SizeConstraint applies to the String and not to the List, right? I always have doubts on this.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Correct, Java allows specifying annotations on values of container types :) . Quite handy and we handle this properly in the YANG generator.


@get:Description("The asymmetric key to use to verify the token signature.")
val keyVerification: AsymmetricSignatureVerificationCryptoStore

/**
* This authenticator will allow only confidential clients.

Choose a reason for hiding this comment

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

Detail: perhaps

Service to obtain the requesting client.

That client must be in [allowedOauthClientIds], if this list is not empty.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

You're right, I should've mentioned it. Adding it.

*
* The OAuth client ID must also be allowed by [allowedOauthClientIds].
*/
val requestingOAuthClient: RequestingOAuthClient
}

/**
Expand Down