Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Extract OAuth 2.0 functionality #131

Merged
merged 8 commits into from
Sep 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,13 @@ Release NEXT:
* Signum 3.7.0 (only dependency updates to align everything, no alignments in code)
* Add `KeyStoreMaterial` to JVM target for convenience
- Update implementation of [OpenID for Verifiable Credential Issuance](https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html) to draft 14 from 2024-08-21
- Move some fields from `IssuerMetadata` to `OAuth2AuthorizationServerMetadata` to match the semantics
- Remove proof type `cwt` for OpenID for Verifiable Credential Issuance, as per draft 14, but keep parsing it for a bit of backwards-compatibility
- OID4VCI: Remove binding method for `did:key`, as it was never completely implemented, but add binding method `jwk` for JSON Web Keys.
- OID4VCI: Rework interface of `WalletService` to make selecting the credential configuration by its ID more explicit
- Move some fields from `IssuerMetadata` to `OAuth2AuthorizationServerMetadata` to match the semantics
- Remove proof type `cwt` for OpenID for Verifiable Credential Issuance, as per draft 14, but keep parsing it for a bit of backwards-compatibility
- Remove binding method for `did:key`, as it was never completely implemented, but add binding method `jwk` for JSON Web Keys.
- Rework interface of `WalletService` to make selecting the credential configuration by its ID more explicit
- Support requesting issuance of credential using scope values
- Introudce `OAuth2Client` to extract creating authentication requests and token requests from OID4VCI `WalletService`
- Refactor `SimpleAuthorizationService` to extract actual authentication and authorization into `AuthorizationServiceStrategy`

Release 4.1.2:
* In `OidcSiopVerifier` add parameter `nonceService` to externalize creation and validation of nonces, e.g. for deployments in load-balanced environments
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ data class OAuth2AuthorizationServerMetadata(
* Can be custom URI scheme, or Universal Links/App links.
*/
@SerialName("authorization_endpoint")
val authorizationEndpoint: String,
val authorizationEndpoint: String? = null,

/**
* RFC 9126: The URL of the pushed authorization request endpoint at which a client can post an authorization
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ data class SupportedCredentialFormatDefinition(
* according to (VC_DATA), Section 4.3, e.g. `VerifiableCredential`, `UniversityDegreeCredential`
*/
@SerialName("type")
val types: Collection<String>? = null,
val types: Set<String>? = null,
JesusMcCloud marked this conversation as resolved.
Show resolved Hide resolved

/**
* OID4VCI:
Expand All @@ -26,7 +26,4 @@ data class SupportedCredentialFormatDefinition(
@SerialName("credentialSubject")
val credentialSubject: Map<String, CredentialSubjectMetadataSingle>? = null,

// TODO is present in EUDIW issuer ... but is this really valid?
@SerialName("claims")
val claims: Map<String, RequestedCredentialClaimSpecification>? = null,
)
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,23 @@ data class TokenRequestParameters(
@SerialName("code")
val code: String? = null,

/**
* RFC6749: OPTIONAL. The authorization and token endpoints allow the client to specify the
* scope of the access request using the "scope" request parameter. In
* turn, the authorization server uses the "scope" response parameter to
* inform the client of the scope of the access token issued.
*/
@SerialName("scope")
val scope: String? = null,

/**
* RFC8707: When requesting a token, the client can indicate the desired target service(s) where it intends to use
* that token by way of the [resource] parameter and can indicate the desired scope of the requested token using the
* [scope] parameter.
*/
@SerialName("resource")
val resource: String? = null,

/**
* RFC6749:
* REQUIRED, if the "redirect_uri" parameter was included in the authorization request,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package at.asitplus.wallet.lib.oauth2

import at.asitplus.openid.AuthenticationRequestParameters
import at.asitplus.openid.AuthorizationDetails
import at.asitplus.openid.OidcUserInfoExtended

/**
* Strategy to implement authentication and authorization in [SimpleAuthorizationService].
*/
interface AuthorizationServiceStrategy {

suspend fun loadUserInfo(request: AuthenticationRequestParameters, code: String): OidcUserInfoExtended?

fun filterAuthorizationDetails(authorizationDetails: Set<AuthorizationDetails>): Set<AuthorizationDetails>


}
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
package at.asitplus.wallet.lib.oauth2

import at.asitplus.openid.*
import at.asitplus.openid.OpenIdConstants.CODE_CHALLENGE_METHOD_SHA256
import at.asitplus.openid.OpenIdConstants.GRANT_TYPE_CODE
import at.asitplus.signum.indispensable.io.Base64UrlStrict
import at.asitplus.wallet.lib.iso.sha256
import at.asitplus.wallet.lib.jws.JwsService
import at.asitplus.wallet.lib.oidvci.*
import io.matthewnelson.encoding.core.Encoder.Companion.encodeToString
import kotlin.random.Random

/**
* Simple OAuth 2.0 client to authorize the client against an OAuth 2.0 Authorization Server and request tokens.
*
* Can be used in OID4VCI flows, e.g. [WalletService].
*/
class OAuth2Client(
/**
* Used to create [AuthenticationRequestParameters], [TokenRequestParameters] and [CredentialRequestProof],
* typically a URI.
*/
private val clientId: String = "https://wallet.a-sit.at/app",
/**
* Used to create [AuthenticationRequestParameters] and [TokenRequestParameters].
*/
private val redirectUrl: String = "$clientId/callback",
/**
* Used to store the code, associated to the state, to first send [AuthenticationRequestParameters.codeChallenge],
* and then [TokenRequestParameters.codeVerifier], see [RFC 7636](https://datatracker.ietf.org/doc/html/rfc7636).
*/
private val stateToCodeStore: MapStore<String, String> = DefaultMapStore(),
) {

/**
* Send the result as parameters (either POST or GET) to the server at `/authorize` (or more specific
* [OAuth2AuthorizationServerMetadata.authorizationEndpoint]).
*
* Sample ktor code:
* ```
* val authnRequest = client.createAuthRequest(...)
* val authnResponse = httpClient.get(issuerMetadata.authorizationEndpointUrl!!) {
* url {
* authnRequest.encodeToParameters().forEach { parameters.append(it.key, it.value) }
* }
* }
* val authn = AuthenticationResponseParameters.deserialize(authnResponse.bodyAsText()).getOrThrow()
* ```
*
* @param state to keep internal state in further requests
* @param scope in OID4VCI flows the value `scope` from [IssuerMetadata.supportedCredentialConfigurations]
* @param authorizationDetails from RFC 9396 OAuth 2.0 Rich Authorization Requests
* @param resource from RFC 8707 Resource Indicators for OAuth 2.0, in OID4VCI flows the value
* of [IssuerMetadata.credentialIssuer]
*/
JesusMcCloud marked this conversation as resolved.
Show resolved Hide resolved
suspend fun createAuthRequest(
state: String,
authorizationDetails: Set<AuthorizationDetails>? = null,
scope: String? = null,
resource: String? = null,
) = AuthenticationRequestParameters(
responseType = GRANT_TYPE_CODE,
state = state,
clientId = clientId,
authorizationDetails = authorizationDetails,
scope = scope,
resource = resource,
redirectUrl = redirectUrl,
codeChallenge = generateCodeVerifier(state),
codeChallengeMethod = CODE_CHALLENGE_METHOD_SHA256,
)

@OptIn(ExperimentalStdlibApi::class)
private suspend fun generateCodeVerifier(state: String): String {
val codeVerifier = Random.nextBytes(32).toHexString(HexFormat.Default)
stateToCodeStore.put(state, codeVerifier)
return codeVerifier.encodeToByteArray().sha256().encodeToString(Base64UrlStrict)
}

sealed class AuthorizationForToken {
/**
* Authorization code from an actual OAuth2 Authorization Server, or [SimpleAuthorizationService.authorize]
*/
data class Code(val code: String) : AuthorizationForToken()

/**
* Pre-auth code from [CredentialOfferGrantsPreAuthCode.preAuthorizedCode] in
* [CredentialOfferGrants.preAuthorizedCode] in [CredentialOffer.grants],
* optionally with a [transactionCode] which is transmitted out-of-band, and may be entered by the user.
*/
data class PreAuthCode(
val preAuthorizedCode: String,
val transactionCode: String? = null
) : AuthorizationForToken()
}

/**
* Request token with an authorization code, e.g. from [createAuthRequest], or pre-auth code.
*
* Send the result as POST parameters (form-encoded) to the server at `/token` (or more specific
* [OAuth2AuthorizationServerMetadata.tokenEndpoint]).
*
* Sample ktor code for authorization code:
* ```
* val authnRequest = client.createAuthRequest(requestOptions)
* val authnResponse = authorizationService.authorize(authnRequest).getOrThrow()
* val code = authnResponse.params.code
* val tokenRequest = client.createTokenRequestParameters(state, AuthorizationForToken.Code(code))
* val tokenResponse = httpClient.submitForm(
* url = issuerMetadata.tokenEndpointUrl!!,
* formParameters = parameters {
* tokenRequest.encodeToParameters().forEach { append(it.key, it.value) }
* }
* )
* val token = TokenResponseParameters.deserialize(tokenResponse.bodyAsText()).getOrThrow()
* ```
*
* Sample ktor code for pre-authn code:
* ```
* val preAuth = credentialOffer.grants.preAuthorizedCode
* val transactionCode = "..." // get from user input
* val authorization = WalletService.AuthorizationForToken.PreAuthCode(preAuth, transactionCode)
* val tokenRequest = client.createTokenRequestParameters(state, authorization)
* val tokenResponse = httpClient.submitForm(
* url = issuerMetadata.tokenEndpointUrl!!,
* formParameters = parameters {
* tokenRequest.encodeToParameters().forEach { append(it.key, it.value) }
* }
* )
* val token = TokenResponseParameters.deserialize(tokenResponse.bodyAsText()).getOrThrow()
* ```
*
* Be sure to include a DPoP header if [OAuth2AuthorizationServerMetadata.dpopSigningAlgValuesSupported] is set,
* see [JwsService.buildDPoPHeader].
*
* @param state to keep internal state in further requests
* @param authorization for the token endpoint
* @param authorizationDetails from RFC 9396 OAuth 2.0 Rich Authorization Requests
* @param scope in OID4VCI flows the value `scope` from [IssuerMetadata.supportedCredentialConfigurations]
* @param resource from RFC 8707 Resource Indicators for OAuth 2.0, in OID4VCI flows the value
* of [IssuerMetadata.credentialIssuer]
*/
suspend fun createTokenRequestParameters(
state: String,
authorization: AuthorizationForToken,
authorizationDetails: Set<AuthorizationDetails>? = null,
scope: String? = null,
resource: String? = null,
) = when (authorization) {
is AuthorizationForToken.Code -> TokenRequestParameters(
grantType = OpenIdConstants.GRANT_TYPE_AUTHORIZATION_CODE,
redirectUrl = redirectUrl,
clientId = clientId,
codeVerifier = stateToCodeStore.remove(state),
authorizationDetails = authorizationDetails,
scope = scope,
resource = resource,
code = authorization.code,
)

is AuthorizationForToken.PreAuthCode -> TokenRequestParameters(
grantType = OpenIdConstants.GRANT_TYPE_PRE_AUTHORIZED_CODE,
redirectUrl = redirectUrl,
clientId = clientId,
codeVerifier = stateToCodeStore.remove(state),
authorizationDetails = authorizationDetails,
scope = scope,
resource = resource,
transactionCode = authorization.transactionCode,
preAuthorizedCode = authorization.preAuthorizedCode,
)
}

}
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
package at.asitplus.wallet.lib.oidvci
package at.asitplus.wallet.lib.oauth2

import at.asitplus.KmmResult
import at.asitplus.catching
import at.asitplus.openid.*
import at.asitplus.signum.indispensable.io.Base64UrlStrict
import at.asitplus.wallet.lib.data.ConstantIndex
import at.asitplus.wallet.lib.iso.sha256
import at.asitplus.wallet.lib.oidc.AuthenticationResponseResult
import at.asitplus.openid.OpenIdConstants.Errors
import at.asitplus.wallet.lib.oidvci.*
import io.github.aakira.napier.Napier
import io.ktor.http.*
import io.matthewnelson.encoding.core.Encoder.Companion.encodeToString
Expand All @@ -16,21 +16,17 @@ import kotlin.time.Duration.Companion.seconds

/**
* Simple authorization server implementation, to be used for [CredentialIssuer],
* when issuing credentials directly from a local [dataProvider].
* with the actual authentication and authorization logic implemented in [strategy].
*
* Implemented from
* [OpenID for Verifiable Credential Issuance](https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html)
* , Draft 14, 2024-08-21.
*/
class SimpleAuthorizationService(
/**
* Source of user data.
* Used to load user data and filter authorization details
*/
private val dataProvider: OAuth2DataProvider,
/**
* List of supported schemes.
*/
private val credentialSchemes: Set<ConstantIndex.CredentialScheme>,
private val strategy: AuthorizationServiceStrategy,
/**
* Used to create and verify authorization codes during issuing.
*/
Expand All @@ -51,21 +47,17 @@ class SimpleAuthorizationService(
* Used to build [OAuth2AuthorizationServerMetadata.authorizationEndpoint], i.e. implementers need to forward requests
* to that URI (which starts with [publicContext]) to [authorize].
*/
val authorizationEndpointPath: String = "/authorize",
private val authorizationEndpointPath: String = "/authorize",
/**
* Used to build [OAuth2AuthorizationServerMetadata.tokenEndpoint], i.e. implementers need to forward requests
* to that URI (which starts with [publicContext]) to [token].
*/
val tokenEndpointPath: String = "/token",
val codeToCodeChallengeStore: MapStore<String, String> = DefaultMapStore(),
val codeToUserInfoStore: MapStore<String, OidcUserInfoExtended> = DefaultMapStore(),
val accessTokenToUserInfoStore: MapStore<String, OidcUserInfoExtended> = DefaultMapStore()
) : OAuth2AuthorizationServer {

val supportedConfigurationIds = credentialSchemes.flatMap { it.toCredentialIdentifier() }
val supportedCredentialSchemes = credentialSchemes
.flatMap { it.toSupportedCredentialFormat().entries }
.associate { it.key to it.value }
private val tokenEndpointPath: String = "/token",
private val codeToCodeChallengeStore: MapStore<String, String> = DefaultMapStore(),
private val codeToUserInfoStore: MapStore<String, OidcUserInfoExtended> = DefaultMapStore(),
private val accessTokenToUserInfoStore: MapStore<String, OidcUserInfoExtended> = DefaultMapStore(),
) : OAuth2AuthorizationServerAdapter {

override val supportsClientNonce: Boolean = true

/**
Expand All @@ -91,11 +83,11 @@ class SimpleAuthorizationService(
throw OAuth2Exception(Errors.INVALID_REQUEST, "redirect_uri not set")
.also { Napier.w("authorize: client did not set redirect_uri in $request") }

val code = codeService.provideCode().also {
val userInfo = dataProvider.loadUserInfo(request)
val code = codeService.provideCode().also { code ->
val userInfo = strategy.loadUserInfo(request, code)
?: throw OAuth2Exception(Errors.INVALID_REQUEST)
.also { Napier.w("authorize: could not load user info from $request") }
codeToUserInfoStore.put(it, userInfo)
codeToUserInfoStore.put(code, userInfo)
}
val responseParams = AuthenticationResponseParameters(
code = code,
Expand Down Expand Up @@ -156,19 +148,7 @@ class SimpleAuthorizationService(
}

val filteredAuthorizationDetails = params.authorizationDetails
?.filterIsInstance<AuthorizationDetails.OpenIdCredential>()
?.filter { authnDetails ->
authnDetails.credentialConfigurationId?.let {
supportedCredentialSchemes.containsKey(it)
} ?: authnDetails.format?.let {
supportedCredentialSchemes.values.any {
it.format == authnDetails.format &&
it.docType == authnDetails.docType &&
it.sdJwtVcType == authnDetails.sdJwtVcType &&
it.credentialDefinition == authnDetails.credentialDefinition
}
} ?: false
}?.toSet()
?.let { strategy.filterAuthorizationDetails(it) }

TokenResponseParameters(
accessToken = tokenService.provideNonce().also {
Expand All @@ -181,17 +161,13 @@ class SimpleAuthorizationService(
).also { Napier.i("token returns $it") }
}

override suspend fun providePreAuthorizedCode(): String? {
return codeService.provideCode().also {
val userInfo = dataProvider.loadUserInfo()
?: return null.also { Napier.w("authorize: could not load user info from data provider") }
codeToUserInfoStore.put(it, userInfo)
override suspend fun providePreAuthorizedCode(user: OidcUserInfoExtended): String =
codeService.provideCode().also {
codeToUserInfoStore.put(it, user)
}
}

override suspend fun verifyClientNonce(nonce: String): Boolean {
return clientNonceService.verifyNonce(nonce)
}
override suspend fun verifyClientNonce(nonce: String): Boolean =
clientNonceService.verifyNonce(nonce)

override suspend fun getUserInfo(accessToken: String): KmmResult<OidcUserInfoExtended> = catching {
if (!tokenService.verifyNonce(accessToken)) {
Expand Down
Loading
Loading