diff --git a/doc/openapi-spec.json b/doc/openapi-spec.json index 2105a02..a428564 100644 --- a/doc/openapi-spec.json +++ b/doc/openapi-spec.json @@ -102,6 +102,7 @@ "Generate a token for Maskinporten": { "value": { "identity_provider": "maskinporten", + "resource": "http://resource.example/api", "target": "altinn:serviceowner/rolesandrights" } } @@ -286,7 +287,7 @@ }, "IntrospectResponse": { "type": "object", - "description": "RFC 7662 introspection response from section 2.2.\n\nIdentity provider's claims differ from one another.\nPlease refer to the Nais documentation for details:\n\n- [Azure AD](https://doc.nais.io/auth/entra-id/reference/#claims)\n- [IDPorten](https://doc.nais.io/auth/idporten/reference/#claims)\n- [Maskinporten](https://doc.nais.io/auth/maskinporten/reference/#claims)\n- [TokenX](https://doc.nais.io/auth/tokenx/reference/#claims)", + "description": "Loosely based on RFC 7662 introspection response from section 2.2.\n\nClaims from the original token are copied verbatim to the introspection response as additional properties.\nThe claims present depend on the identity provider.\nPlease refer to the Nais documentation for details:\n\n- [Azure AD](https://doc.nais.io/auth/entra-id/reference/#claims)\n- [IDPorten](https://doc.nais.io/auth/idporten/reference/#claims)\n- [Maskinporten](https://doc.nais.io/auth/maskinporten/reference/#claims)\n- [TokenX](https://doc.nais.io/auth/tokenx/reference/#claims)", "required": [ "active" ], @@ -333,7 +334,7 @@ }, "target": { "type": "string", - "description": "The issued token will only be accepted by the targeted application, specified in this field." + "description": "Scope or identifier for the target application." }, "user_token": { "type": "string", @@ -352,9 +353,16 @@ "identity_provider": { "$ref": "#/components/schemas/IdentityProvider" }, + "resource": { + "type": [ + "string", + "null" + ], + "description": "Resource indicator for audience-restricted tokens (RFC 8707)." + }, "target": { "type": "string", - "description": "The issued token will only be accepted by the targeted application, specified in this field." + "description": "Scope or identifier for the target application." } } }, diff --git a/hack/roundtrip-maskinporten.sh b/hack/roundtrip-maskinporten.sh index 31d8cbd..56d2881 100755 --- a/hack/roundtrip-maskinporten.sh +++ b/hack/roundtrip-maskinporten.sh @@ -1,5 +1,5 @@ #!/bin/bash -e -response=$(curl -s -X POST http://localhost:3000/api/v1/token -H "content-type: application/json" -d '{"target": "my-target", "identity_provider": "maskinporten"}') +response=$(curl -s -X POST http://localhost:3000/api/v1/token -H "content-type: application/json" -d '{"target": "my-target", "identity_provider": "maskinporten", "resource": "some-resource"}') token=$(echo ${response} | jq -r .access_token) validation=$(curl -s -X POST http://localhost:3000/api/v1/introspect -H "content-type: application/json" -d "{\"token\": \"${token}\", \"identity_provider\": \"maskinporten\"}") diff --git a/src/app.rs b/src/app.rs index e6c1248..4d655ec 100644 --- a/src/app.rs +++ b/src/app.rs @@ -496,6 +496,7 @@ mod tests { TokenRequest { target: "invalid".to_string(), identity_provider: IdentityProvider::AzureAD, + resource: None, }, Json, ) @@ -586,7 +587,11 @@ mod tests { async fn machine_to_machine_token(expected_issuer: String, target: String, address: String, identity_provider: IdentityProvider, request_format: RequestFormat) { let response = post_request( format!("http://{}/api/v1/token", address.clone().to_string()), - TokenRequest { target, identity_provider }, + TokenRequest { + target, + identity_provider, + resource: None + }, request_format.clone(), ) .await diff --git a/src/claims.rs b/src/claims.rs index b0a6fbb..c27ca2e 100644 --- a/src/claims.rs +++ b/src/claims.rs @@ -4,7 +4,7 @@ use serde::Serialize; const EXPIRY_LEEWAY_SECONDS: usize = 30; pub trait Assertion: Send + Sync + Serialize { - fn new(token_endpoint: String, client_id: String, target: String) -> Self; + fn new(token_endpoint: String, client_id: String, target: String, resource: Option) -> Self; } #[derive(Serialize)] @@ -27,10 +27,12 @@ pub struct JWTBearerAssertion { scope: String, iss: String, aud: String, + #[serde(skip_serializing_if = "Option::is_none")] + resource: Option, } impl Assertion for JWTBearerAssertion { - fn new(token_endpoint: String, client_id: String, target: String) -> Self { + fn new(token_endpoint: String, client_id: String, target: String, resource: Option) -> Self { let now = epoch_now_secs(); let jti = uuid::Uuid::new_v4(); @@ -42,12 +44,13 @@ impl Assertion for JWTBearerAssertion { iss: client_id, // issuer of the token is the client itself aud: token_endpoint, // audience of the token is the issuer scope: target, + resource, // resource indicator for audience-restricted tokens } } } impl Assertion for ClientAssertion { - fn new(token_endpoint: String, client_id: String, _target: String) -> Self { + fn new(token_endpoint: String, client_id: String, _target: String, _resource: Option) -> Self { let now = epoch_now_secs(); let jti = uuid::Uuid::new_v4(); @@ -64,7 +67,7 @@ impl Assertion for ClientAssertion { } impl Assertion for () { - fn new(_token_endpoint: String, _client_id: String, _target: String) -> Self {} + fn new(_token_endpoint: String, _client_id: String, _target: String, _resource: Option) -> Self {} } pub fn epoch_now_secs() -> u64 { diff --git a/src/handlers.rs b/src/handlers.rs index 7216475..5042d53 100644 --- a/src/handlers.rs +++ b/src/handlers.rs @@ -33,10 +33,12 @@ use tracing::instrument; ("Generate a token for Maskinporten" = (value = json!(TokenRequest{ identity_provider: IdentityProvider::Maskinporten, target: "altinn:serviceowner/rolesandrights".to_string(), + resource: Some("http://resource.example/api".to_string()), }))), ("Generate a token for Azure AD" = (value = json!(TokenRequest{ identity_provider: IdentityProvider::AzureAD, target: "api://cluster.namespace.application/.default".to_string(), + resource: None, }))), ), ), diff --git a/src/identity_provider.rs b/src/identity_provider.rs index 48cad8d..d8c6f94 100644 --- a/src/identity_provider.rs +++ b/src/identity_provider.rs @@ -34,9 +34,10 @@ pub enum TokenType { Bearer, } -/// RFC 7662 introspection response from section 2.2. +/// Loosely based on RFC 7662 introspection response from section 2.2. /// -/// Identity provider's claims differ from one another. +/// Claims from the original token are copied verbatim to the introspection response as additional properties. +/// The claims present depend on the identity provider. /// Please refer to the Nais documentation for details: /// /// - [Azure AD](https://doc.nais.io/auth/entra-id/reference/#claims) @@ -147,15 +148,18 @@ impl Display for IdentityProvider { /// Use this data type to request a machine token. #[derive(Serialize, Deserialize, ToSchema, Clone, Debug)] pub struct TokenRequest { - /// The issued token will only be accepted by the targeted application, specified in this field. + /// Scope or identifier for the target application. pub target: String, pub identity_provider: IdentityProvider, + /// Resource indicator for audience-restricted tokens (RFC 8707). + #[serde(skip_serializing_if = "Option::is_none")] + pub resource: Option, } /// Use this data type to exchange a user token for a machine token. #[derive(Serialize, Deserialize, ToSchema, Clone, Debug)] pub struct TokenExchangeRequest { - /// The issued token will only be accepted by the targeted application, specified in this field. + /// Scope or identifier for the target application. pub target: String, pub identity_provider: IdentityProvider, @@ -265,8 +269,8 @@ where } #[instrument(skip_all, name = "Create assertion for token signing request")] - fn create_assertion(&self, target: String) -> Option { - let assertion = A::new(self.token_endpoint.as_ref()?.clone(), self.client_id.clone(), target); + fn create_assertion(&self, target: String, resource: Option) -> Option { + let assertion = A::new(self.token_endpoint.as_ref()?.clone(), self.client_id.clone(), target, resource); serialize(assertion, self.client_assertion_header.as_ref()?, self.private_jwk.as_ref()?).ok() } } @@ -281,7 +285,7 @@ where async fn get_token(&self, request: TokenRequest) -> Result { let token_request = TokenRequestBuilderParams { target: request.target.clone(), - assertion: self.create_assertion(request.target).ok_or(ApiError::TokenRequestUnsupported(self.identity_provider_kind))?, + assertion: self.create_assertion(request.target, request.resource).ok_or(ApiError::TokenRequestUnsupported(self.identity_provider_kind))?, client_id: Some(self.client_id.clone()), user_token: None, }; @@ -291,7 +295,7 @@ where async fn exchange_token(&self, request: TokenExchangeRequest) -> Result { let token_request = TokenRequestBuilderParams { target: request.target.clone(), - assertion: self.create_assertion(request.target).ok_or(ApiError::TokenExchangeUnsupported(self.identity_provider_kind))?, + assertion: self.create_assertion(request.target, None).ok_or(ApiError::TokenExchangeUnsupported(self.identity_provider_kind))?, client_id: Some(self.client_id.clone()), user_token: Some(request.user_token), };