huskarl 0.9.1

A modern OAuth2 client library.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
//! Client metadata (RFC 7591 §2).

use std::collections::HashMap;

use bon::Builder;
use serde::{Deserialize, Serialize};

use crate::core::{jwk::PublicJwks, secrets::SecretString};

/// Client metadata values associated with a client at an authorization server
/// (RFC 7591 §2).
///
/// Used both as the input to a registration request (the values the client
/// *requests*) and, flattened into [`ClientInformationResponse`], as the values
/// the server actually *registered*. Every field is optional: omitted fields
/// are left off the wire so the server applies its own defaults. The server MAY
/// substitute or reject requested values, so always consult the response.
///
/// Beyond the RFC 7591 §2 base parameters, the `OpenID` Connect Dynamic Client
/// Registration 1.0 §2 extension parameters are also typed. Any other extension
/// parameter round-trips through [`extra`](Self::extra).
///
/// [`ClientInformationResponse`]: super::ClientInformationResponse
#[derive(Clone, Debug, Default, Serialize, Deserialize, Builder)]
#[builder(on(String, into))]
#[non_exhaustive]
pub struct ClientMetadata {
    /// Redirection URIs to register for the client's redirect-based flows.
    /// Matched by exact string at the authorization server, so register each
    /// exactly as the client will send it.
    // Plain strings, not EndpointUrl: EndpointUrl normalizes (and rejects
    // native-app schemes, RFC 8252), which would break the exact-string match.
    // Same reason the authorization code grant uses `redirect_uri: String`.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    #[builder(default)]
    pub redirect_uris: Vec<String>,

    /// Requested client authentication method for the token endpoint
    /// (e.g. `none`, `client_secret_post`, `client_secret_basic`).
    ///
    /// If omitted, the server default is `client_secret_basic`.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub token_endpoint_auth_method: Option<String>,

    /// OAuth 2.0 grant type strings the client will use at the token endpoint.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    #[builder(default)]
    pub grant_types: Vec<String>,

    /// OAuth 2.0 response type strings the client will use at the authorization
    /// endpoint.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    #[builder(default)]
    pub response_types: Vec<String>,

    /// Human-readable name of the client, presented to the end-user.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub client_name: Option<String>,

    /// URL of a web page with information about the client.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub client_uri: Option<String>,

    /// URL referencing a logo for the client.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub logo_uri: Option<String>,

    /// Scope values the client can request. Modeled as a list; serialized as the
    /// single space-delimited `scope` string RFC 7591 §2 defines.
    #[serde(
        default,
        skip_serializing_if = "scope_serde::is_empty",
        serialize_with = "scope_serde::serialize",
        deserialize_with = "scope_serde::deserialize"
    )]
    pub scope: Option<Vec<String>>,

    /// Ways to contact people responsible for the client, typically email
    /// addresses.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    #[builder(default)]
    pub contacts: Vec<String>,

    /// URL pointing to a human-readable terms of service document.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub tos_uri: Option<String>,

    /// URL pointing to a human-readable privacy policy document.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub policy_uri: Option<String>,

    /// URL referencing the client's JWK Set document.
    ///
    /// Per RFC 7591 §2, MUST NOT be present together with [`jwks`](Self::jwks).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub jwks_uri: Option<String>,

    /// The client's public JWK Set, inline (RFC 7591 §2).
    ///
    /// The builder accepts anything `Into<PublicJwks>` — including a
    /// [`Jwks`](crate::core::jwk::Jwks), whose private material is stripped, so
    /// only public keys reach the wire. MUST NOT be present together with
    /// [`jwks_uri`](Self::jwks_uri).
    // Deserialized via Jwks (PublicJwks isn't Deserialize), then reduced to its
    // public keys, so private/symmetric keys a server echoes can't enter the field.
    #[serde(
        default,
        skip_serializing_if = "Option::is_none",
        deserialize_with = "jwks_serde::deserialize"
    )]
    #[builder(into)]
    pub jwks: Option<PublicJwks>,

    /// A unique identifier for the client software, stable across instances.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub software_id: Option<String>,

    /// A version identifier for the client software.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub software_version: Option<String>,

    /// A software statement: a signed JWT asserting client metadata (RFC 7591
    /// §2.3), supplied pre-built. Serialized as the raw JWT.
    // SecretString, not String: the statement is bearer-presentable (no
    // proof-of-possession), so a leak lets a holder register as that software.
    #[serde(skip_serializing_if = "Option::is_none")]
    #[builder(into)]
    pub software_statement: Option<SecretString>,

    /// Kind of client application — `web` (the default) or `native`
    /// (`OpenID` Connect Dynamic Client Registration 1.0 §2).
    ///
    /// `OpenID` Connect Dynamic Client Registration 1.0
    #[serde(skip_serializing_if = "Option::is_none")]
    pub application_type: Option<String>,

    /// URL of a JSON file with the client's `redirect_uris`, providing the host
    /// the server uses when calculating `pairwise` `sub` values (`OpenID`
    /// Connect Dynamic Client Registration 1.0 §2).
    ///
    /// `OpenID` Connect Dynamic Client Registration 1.0
    #[serde(skip_serializing_if = "Option::is_none")]
    pub sector_identifier_uri: Option<String>,

    /// Requested subject identifier type — `pairwise` or `public`
    /// (`OpenID` Connect Dynamic Client Registration 1.0 §2).
    ///
    /// `OpenID` Connect Dynamic Client Registration 1.0
    #[serde(skip_serializing_if = "Option::is_none")]
    pub subject_type: Option<String>,

    /// JWS `alg` the client requests for signing the ID token
    /// (`OpenID` Connect Dynamic Client Registration 1.0 §2).
    ///
    /// `OpenID` Connect Dynamic Client Registration 1.0
    #[serde(skip_serializing_if = "Option::is_none")]
    pub id_token_signed_response_alg: Option<String>,

    /// JWE `alg` the client requests for encrypting the ID token
    /// (`OpenID` Connect Dynamic Client Registration 1.0 §2).
    ///
    /// `OpenID` Connect Dynamic Client Registration 1.0
    #[serde(skip_serializing_if = "Option::is_none")]
    pub id_token_encrypted_response_alg: Option<String>,

    /// JWE `enc` the client requests for encrypting the ID token; only valid
    /// alongside [`id_token_encrypted_response_alg`](Self::id_token_encrypted_response_alg)
    /// (`OpenID` Connect Dynamic Client Registration 1.0 §2).
    ///
    /// `OpenID` Connect Dynamic Client Registration 1.0
    #[serde(skip_serializing_if = "Option::is_none")]
    pub id_token_encrypted_response_enc: Option<String>,

    /// JWS `alg` the client requests for signing `UserInfo` responses; if set,
    /// the response is returned as a signed JWT
    /// (`OpenID` Connect Dynamic Client Registration 1.0 §2).
    ///
    /// `OpenID` Connect Dynamic Client Registration 1.0
    #[serde(skip_serializing_if = "Option::is_none")]
    pub userinfo_signed_response_alg: Option<String>,

    /// JWE `alg` the client requests for encrypting `UserInfo` responses
    /// (`OpenID` Connect Dynamic Client Registration 1.0 §2).
    ///
    /// `OpenID` Connect Dynamic Client Registration 1.0
    #[serde(skip_serializing_if = "Option::is_none")]
    pub userinfo_encrypted_response_alg: Option<String>,

    /// JWE `enc` the client requests for encrypting `UserInfo` responses; only
    /// valid alongside [`userinfo_encrypted_response_alg`](Self::userinfo_encrypted_response_alg)
    /// (`OpenID` Connect Dynamic Client Registration 1.0 §2).
    ///
    /// `OpenID` Connect Dynamic Client Registration 1.0
    #[serde(skip_serializing_if = "Option::is_none")]
    pub userinfo_encrypted_response_enc: Option<String>,

    /// JWS `alg` the client requests for signing Request Objects, i.e. the JAR
    /// request object (RFC 9101 §10.5; `OpenID` Connect Dynamic Client
    /// Registration 1.0 §2).
    ///
    /// `OpenID` Connect Dynamic Client Registration 1.0
    #[serde(skip_serializing_if = "Option::is_none")]
    pub request_object_signing_alg: Option<String>,

    /// JWE `alg` the client requests for encrypting Request Objects
    /// (`OpenID` Connect Dynamic Client Registration 1.0 §2).
    ///
    /// `OpenID` Connect Dynamic Client Registration 1.0
    #[serde(skip_serializing_if = "Option::is_none")]
    pub request_object_encryption_alg: Option<String>,

    /// JWE `enc` the client requests for encrypting Request Objects; only valid
    /// alongside [`request_object_encryption_alg`](Self::request_object_encryption_alg)
    /// (`OpenID` Connect Dynamic Client Registration 1.0 §2).
    ///
    /// `OpenID` Connect Dynamic Client Registration 1.0
    #[serde(skip_serializing_if = "Option::is_none")]
    pub request_object_encryption_enc: Option<String>,

    /// JWS `alg` the client uses to sign the authentication JWT for the
    /// `private_key_jwt` and `client_secret_jwt` methods at the token endpoint
    /// (`OpenID` Connect Dynamic Client Registration 1.0 §2).
    ///
    /// `OpenID` Connect Dynamic Client Registration 1.0
    #[serde(skip_serializing_if = "Option::is_none")]
    pub token_endpoint_auth_signing_alg: Option<String>,

    /// Default maximum authentication age, in seconds: if the end-user was last
    /// authenticated longer ago, they must be re-authenticated
    /// (`OpenID` Connect Dynamic Client Registration 1.0 §2).
    ///
    /// `OpenID` Connect Dynamic Client Registration 1.0
    #[serde(skip_serializing_if = "Option::is_none")]
    pub default_max_age: Option<u64>,

    /// Whether the `auth_time` claim is required in issued ID tokens
    /// (`OpenID` Connect Dynamic Client Registration 1.0 §2).
    ///
    /// `OpenID` Connect Dynamic Client Registration 1.0
    #[serde(skip_serializing_if = "Option::is_none")]
    pub require_auth_time: Option<bool>,

    /// Default requested Authentication Context Class Reference (`acr`) values
    /// (`OpenID` Connect Dynamic Client Registration 1.0 §2).
    ///
    /// `OpenID` Connect Dynamic Client Registration 1.0
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    #[builder(default)]
    pub default_acr_values: Vec<String>,

    /// `https` URI a third party can use to initiate a login at the client
    /// (`OpenID` Connect Dynamic Client Registration 1.0 §2).
    ///
    /// `OpenID` Connect Dynamic Client Registration 1.0
    #[serde(skip_serializing_if = "Option::is_none")]
    pub initiate_login_uri: Option<String>,

    /// `request_uri` values pre-registered for use at the authorization endpoint
    /// (`OpenID` Connect Dynamic Client Registration 1.0 §2).
    ///
    /// `OpenID` Connect Dynamic Client Registration 1.0
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    #[builder(default)]
    pub request_uris: Vec<String>,

    /// Extension metadata parameters beyond those typed above.
    #[serde(flatten)]
    #[builder(default)]
    pub extra: HashMap<String, serde_json::Value>,
}

/// Deserializes the `jwks` member through [`Jwks`](crate::core::jwk::Jwks)
/// (the only deserializable JWK Set type) and reduces it to a
/// [`PublicJwks`], so a server that echoes private
/// or symmetric material never lands in the public field.
mod jwks_serde {
    use serde::{Deserialize, Deserializer};

    use crate::core::jwk::{Jwks, PublicJwks};

    pub(super) fn deserialize<'de, D: Deserializer<'de>>(
        deserializer: D,
    ) -> Result<Option<PublicJwks>, D::Error> {
        Ok(Option::<Jwks>::deserialize(deserializer)?.map(PublicJwks::from))
    }
}

/// (De)serializes the RFC 7591 §2 `scope` member as the single space-delimited
/// string the wire uses, while modeling it as a list of scope values in Rust
/// (matching how the grant builders carry scopes).
// `&Option<_>` signatures are required by serde's `skip_serializing_if` /
// `serialize_with`, which hand the field by shared reference.
#[allow(clippy::ref_option)]
mod scope_serde {
    use serde::{Deserialize, Deserializer, Serializer};

    pub(super) fn is_empty(scope: &Option<Vec<String>>) -> bool {
        scope.as_ref().is_none_or(Vec::is_empty)
    }

    pub(super) fn serialize<S: Serializer>(
        scope: &Option<Vec<String>>,
        serializer: S,
    ) -> Result<S::Ok, S::Error> {
        match scope {
            Some(values) => serializer.serialize_str(&values.join(" ")),
            None => serializer.serialize_none(),
        }
    }

    pub(super) fn deserialize<'de, D: Deserializer<'de>>(
        deserializer: D,
    ) -> Result<Option<Vec<String>>, D::Error> {
        Ok(Option::<String>::deserialize(deserializer)?
            .map(|s| s.split_whitespace().map(str::to_owned).collect()))
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn omitted_fields_are_absent_on_the_wire() {
        let metadata = ClientMetadata::builder().client_name("My App").build();
        let value = serde_json::to_value(&metadata).unwrap();
        let object = value.as_object().unwrap();

        assert_eq!(object.get("client_name").unwrap(), "My App");
        // Optionals and empty collections must not be serialized, so the
        // server fills its own defaults rather than seeing empty values.
        assert!(!object.contains_key("redirect_uris"));
        assert!(!object.contains_key("token_endpoint_auth_method"));
        assert!(!object.contains_key("grant_types"));
        assert!(!object.contains_key("jwks"));
    }

    #[test]
    fn software_statement_is_secret_on_the_type_but_plaintext_on_the_wire() {
        let jwt = "eyJhbGciOiJSUzI1NiJ9.eyJzb2Z0d2FyZV9pZCI6IngifQ.sig";
        let metadata = ClientMetadata::builder().software_statement(jwt).build();

        // Serializes to the raw JWT so the server receives a valid statement.
        let value = serde_json::to_value(&metadata).unwrap();
        assert_eq!(value.get("software_statement").unwrap(), jwt);

        // ...but is redacted from Debug so it cannot leak into logs.
        let debug = format!("{metadata:?}");
        assert!(
            !debug.contains(jwt),
            "software_statement leaked in Debug: {debug}"
        );

        // Round-trips back from the wire.
        let parsed: ClientMetadata = serde_json::from_value(value).unwrap();
        assert_eq!(parsed.software_statement.unwrap().expose_secret(), jwt);
    }

    #[test]
    fn jwks_is_a_public_key_set_object_on_the_wire_and_round_trips() {
        let metadata = ClientMetadata::builder()
            .jwks(PublicJwks::new(vec![]))
            .build();

        let value = serde_json::to_value(&metadata).unwrap();
        assert!(value.get("jwks").unwrap().is_object());
        assert_eq!(value["jwks"]["keys"], serde_json::json!([]));

        // A server's echoed JWK Set object deserializes back into the typed field.
        let parsed: ClientMetadata =
            serde_json::from_value(serde_json::json!({ "jwks": { "keys": [] } })).unwrap();
        assert_eq!(parsed.jwks, Some(PublicJwks::new(vec![])));
    }

    #[test]
    fn oidc_dcr_parameters_are_typed_under_their_wire_names_and_round_trip() {
        let metadata = ClientMetadata::builder()
            .application_type("web")
            .request_object_signing_alg("ES256")
            .token_endpoint_auth_signing_alg("RS256")
            .default_max_age(3600)
            .require_auth_time(true)
            .default_acr_values(vec!["urn:mace:incommon:iap:silver".to_owned()])
            .request_uris(vec!["https://app.example/req/1".to_owned()])
            .build();

        let value = serde_json::to_value(&metadata).unwrap();
        // OIDC DCR parameters serialize under their spec wire names, not nested
        // under `extra`.
        assert_eq!(value["application_type"], "web");
        assert_eq!(value["request_object_signing_alg"], "ES256");
        assert_eq!(value["token_endpoint_auth_signing_alg"], "RS256");
        assert_eq!(value["default_max_age"], 3600);
        assert_eq!(value["require_auth_time"], true);
        assert_eq!(
            value["default_acr_values"],
            serde_json::json!(["urn:mace:incommon:iap:silver"])
        );
        assert_eq!(
            value["request_uris"],
            serde_json::json!(["https://app.example/req/1"])
        );

        // A server echo deserializes back into the typed fields, leaving `extra`
        // empty.
        let parsed: ClientMetadata = serde_json::from_value(value).unwrap();
        assert_eq!(parsed.application_type.as_deref(), Some("web"));
        assert_eq!(parsed.request_object_signing_alg.as_deref(), Some("ES256"));
        assert_eq!(parsed.default_max_age, Some(3600));
        assert_eq!(parsed.require_auth_time, Some(true));
        assert!(
            parsed.extra.is_empty(),
            "typed fields must not fall into extra: {:?}",
            parsed.extra
        );
    }

    #[test]
    fn scope_is_a_space_delimited_string_on_the_wire_and_round_trips() {
        let metadata = ClientMetadata::builder()
            .scope(bon::vec!["openid", "profile", "email"])
            .build();

        // RFC 7591 §2: `scope` is one space-delimited string, not a JSON array.
        let value = serde_json::to_value(&metadata).unwrap();
        assert_eq!(value["scope"], "openid profile email");

        // A server echo splits back into the modeled list.
        let parsed: ClientMetadata = serde_json::from_value(value).unwrap();
        assert_eq!(
            parsed.scope,
            Some(vec![
                "openid".to_owned(),
                "profile".to_owned(),
                "email".to_owned()
            ])
        );

        // An empty scope is omitted, like the other empty collections.
        let empty = ClientMetadata::builder().scope(vec![]).build();
        let value = serde_json::to_value(&empty).unwrap();
        assert!(!value.as_object().unwrap().contains_key("scope"));
    }

    #[test]
    fn extension_parameters_round_trip_through_extra() {
        let json = serde_json::json!({
            "client_name": "My App",
            "redirect_uris": ["https://app.example/cb"],
            "example_extension_parameter": "value",
        });

        let metadata: ClientMetadata = serde_json::from_value(json).unwrap();
        assert_eq!(metadata.client_name.as_deref(), Some("My App"));
        assert_eq!(metadata.redirect_uris, vec!["https://app.example/cb"]);
        assert_eq!(
            metadata.extra.get("example_extension_parameter").unwrap(),
            "value"
        );

        // Re-serializing keeps the extension parameter as a top-level member.
        let value = serde_json::to_value(&metadata).unwrap();
        assert_eq!(value.get("example_extension_parameter").unwrap(), "value");
    }
}