Skip to content
Merged
35 changes: 35 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
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
31 changes: 31 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
name: publish.yml
on:
push:
tags:
- 'v[0-9]+.[0-9]+.[0-9]+'

jobs:
package:
name: Build Package
runs-on: ubuntu-latest

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
id: setup-maven-oauth
uses: curityio/curity-maven-gh-action@v1
with:
client-secret: ${{ secrets.CURITY_CLI_CLIENT_SECRET }}

- name: Publish package
run: mvn deploy ${{ steps.setup-maven-oauth.outputs.maven-deploy-args }}
38 changes: 38 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# 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.2] - 2025-11-17

- Changed plugin implementation type from `access_token` to `access-token`.

## [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

27 changes: 22 additions & 5 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,18 @@ 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
~~~~~~~

This plugin can only be used to authenticate users via `HAAPI`_, hence the OAuth client initiating authorization MUST be
a confidential client with the HAAPI capability.

If a user tries to run an authentication flow with this authenticator from a browser, they will see an error.

Overview
~~~~~~~~

This plugin allows users to authenticate using `HAAPI`_ assuming they have already obtained 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 +31,21 @@ 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::
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.

.. 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.2</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,23 +21,24 @@ 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

object AccessTokenAuthenticatorConstants {
const val PLUGIN_TYPE = "access_token"
const val PLUGIN_TYPE = "access-token"
const val TEMPLATE_NAME = "authenticate/start"
}

/**
* 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,20 @@ 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

/**
* Service to obtain the requesting OAuth client.
*
* This authenticator will allow only confidential clients. Consequently, the client must be present.
*
* Finally, the OAuth client must be in [allowedOauthClientIds] if that List is not empty.
*/
val requestingOAuthClient: RequestingOAuthClient
}

/**
Expand Down