diff --git a/pkgs/oauth2/CHANGELOG.md b/pkgs/oauth2/CHANGELOG.md index e197a7baa8..790d16e96a 100644 --- a/pkgs/oauth2/CHANGELOG.md +++ b/pkgs/oauth2/CHANGELOG.md @@ -1,3 +1,10 @@ +## 2.1.0 + +* Add support for RFC 8414 (Authorization Server Metadata Discovery) and RFC 9728 (Protected Resource Metadata Discovery). +* Add support for RFC 7591 (Dynamic Client Registration). +* Add support for RFC 8707 (Resource Indicators) via `resources` parameter in grants and credentials refresh. +* Add support for custom client authenticators via `customAuth` parameter in `AuthorizationCodeGrant` and `ClientCredentialsGrant`. + ## 2.0.5 * Make underlying HTTP client non-nullable to inherit its close behavior. diff --git a/pkgs/oauth2/lib/oauth2.dart b/pkgs/oauth2/lib/oauth2.dart index 45efc5c1bf..5bdb1c6ab6 100644 --- a/pkgs/oauth2/lib/oauth2.dart +++ b/pkgs/oauth2/lib/oauth2.dart @@ -5,7 +5,10 @@ export 'src/authorization_code_grant.dart'; export 'src/authorization_exception.dart'; export 'src/client.dart'; +export 'src/client_authenticator.dart'; export 'src/client_credentials_grant.dart'; export 'src/credentials.dart'; +export 'src/discovery.dart'; export 'src/expiration_exception.dart'; +export 'src/registration.dart'; export 'src/resource_owner_password_grant.dart'; diff --git a/pkgs/oauth2/lib/src/authorization_code_grant.dart b/pkgs/oauth2/lib/src/authorization_code_grant.dart index fac56ba0d0..8b8c7c0b9b 100644 --- a/pkgs/oauth2/lib/src/authorization_code_grant.dart +++ b/pkgs/oauth2/lib/src/authorization_code_grant.dart @@ -12,6 +12,7 @@ import 'package:http_parser/http_parser.dart'; import 'authorization_exception.dart'; import 'client.dart'; +import 'client_authenticator.dart'; import 'credentials.dart'; import 'handle_access_token_response.dart'; import 'parameters.dart'; @@ -80,6 +81,9 @@ class AuthorizationCodeGrant { /// This will be passed as-is to the constructed [Client]. final CredentialsRefreshedCallback? _onCredentialsRefreshed; + /// Custom client authenticator for injecting assertions or specific headers. + final ClientAuthenticator? _customAuth; + /// Whether to use HTTP Basic authentication for authorizing the client. final bool _basicAuth; @@ -145,7 +149,14 @@ class AuthorizationCodeGrant { /// as its body as a UTF-8-decoded string. It should return a map in the same /// format as the [standard JSON response][]. /// + /// [customAuth] is an optional callback to add additional client + /// authentication headers or body parameters to a token request for advanced + /// scenarios, such as when using a JWT Bearer token for client authentication + /// per [RFC 7523]. When provided, it replaces the default `basicAuth` + /// credentials integration in token requests. + /// /// [standard JSON response]: https://tools.ietf.org/html/rfc6749#section-5.1 + /// [RFC 7523]: https://tools.ietf.org/html/rfc7523#section-2.2 AuthorizationCodeGrant( this.identifier, this.authorizationEndpoint, this.tokenEndpoint, {this.secret, @@ -155,12 +166,14 @@ class AuthorizationCodeGrant { CredentialsRefreshedCallback? onCredentialsRefreshed, Map Function(MediaType? contentType, String body)? getParameters, - String? codeVerifier}) + String? codeVerifier, + ClientAuthenticator? customAuth}) : _basicAuth = basicAuth, _httpClient = httpClient ?? http.Client(), _delimiter = delimiter ?? ' ', _getParameters = getParameters ?? parseJsonParameters, _onCredentialsRefreshed = onCredentialsRefreshed, + _customAuth = customAuth, _codeVerifier = codeVerifier ?? _createCodeVerifier(); /// Returns the URL to which the resource owner should be redirected to @@ -183,7 +196,7 @@ class AuthorizationCodeGrant { /// /// It is a [StateError] to call this more than once. Uri getAuthorizationUrl(Uri redirect, - {Iterable? scopes, String? state}) { + {Iterable? scopes, String? state, Iterable? resources}) { if (_state != _State.initial) { throw StateError('The authorization URL has already been generated.'); } @@ -197,7 +210,7 @@ class AuthorizationCodeGrant { _redirectEndpoint = redirect; _scopes = scopeList; _stateString = state; - var parameters = { + var parameters = { 'response_type': 'code', 'client_id': identifier, 'redirect_uri': redirect.toString(), @@ -207,6 +220,9 @@ class AuthorizationCodeGrant { if (state != null) parameters['state'] = state; if (scopeList.isNotEmpty) parameters['scope'] = scopeList.join(_delimiter); + if (resources != null && resources.isNotEmpty) { + parameters['resource'] = resources.map((r) => r.toString()).toList(); + } return addQueryParameters(authorizationEndpoint, parameters); } @@ -297,15 +313,18 @@ class AuthorizationCodeGrant { var headers = {}; - var body = { + var body = { 'grant_type': 'authorization_code', - 'code': authorizationCode, - 'redirect_uri': _redirectEndpoint.toString(), + if (authorizationCode != null) 'code': authorizationCode, + if (_redirectEndpoint != null) + 'redirect_uri': _redirectEndpoint.toString(), 'code_verifier': _codeVerifier }; var secret = this.secret; - if (_basicAuth && secret != null) { + if (_customAuth != null) { + await _customAuth(headers, body); + } else if (_basicAuth && secret != null) { headers['Authorization'] = basicAuthHeader(identifier, secret); } else { // The ID is required for this request any time basic auth isn't being @@ -325,6 +344,7 @@ class AuthorizationCodeGrant { secret: secret, basicAuth: _basicAuth, httpClient: _httpClient, + customAuth: _customAuth, onCredentialsRefreshed: _onCredentialsRefreshed); } diff --git a/pkgs/oauth2/lib/src/client.dart b/pkgs/oauth2/lib/src/client.dart index 3534df3736..bddbb880a6 100644 --- a/pkgs/oauth2/lib/src/client.dart +++ b/pkgs/oauth2/lib/src/client.dart @@ -9,6 +9,7 @@ import 'package:http/http.dart' as http; import 'package:http_parser/http_parser.dart'; import 'authorization_exception.dart'; +import 'client_authenticator.dart'; import 'credentials.dart'; import 'expiration_exception.dart'; @@ -69,6 +70,9 @@ class Client extends http.BaseClient { /// Callback to be invoked whenever the credentials refreshed. final CredentialsRefreshedCallback? _onCredentialsRefreshed; + /// Custom client authenticator for injecting assertions or specific headers. + final ClientAuthenticator? _customAuth; + /// Whether to use HTTP Basic authentication for authorizing the client. final bool _basicAuth; @@ -84,17 +88,27 @@ class Client extends http.BaseClient { /// [httpClient] is the underlying client that this forwards requests to after /// adding authorization credentials to them. /// + /// [customAuth] is an optional callback to add additional client + /// authentication headers or body parameters to a token request for advanced + /// scenarios, such as when using a JWT Bearer token for client authentication + /// per [RFC 7523]. When provided, it replaces the default `basicAuth` + /// credentials integration during refresh token requests. + /// /// Throws an [ArgumentError] if [secret] is passed without [identifier]. + /// + /// [RFC 7523]: https://tools.ietf.org/html/rfc7523#section-2.2 Client(this._credentials, {this.identifier, this.secret, CredentialsRefreshedCallback? onCredentialsRefreshed, bool basicAuth = true, + ClientAuthenticator? customAuth, http.Client? httpClient}) : _basicAuth = basicAuth, _onCredentialsRefreshed = onCredentialsRefreshed, + _customAuth = customAuth, _httpClient = httpClient ?? http.Client() { - if (identifier == null && secret != null) { + if (_customAuth == null && identifier == null && secret != null) { throw ArgumentError('secret may not be passed without identifier.'); } } @@ -164,6 +178,7 @@ class Client extends http.BaseClient { secret: secret, newScopes: newScopes, basicAuth: _basicAuth, + customAuth: _customAuth, httpClient: _httpClient, ); _credentials = await _refreshingFuture!; diff --git a/pkgs/oauth2/lib/src/client_authenticator.dart b/pkgs/oauth2/lib/src/client_authenticator.dart new file mode 100644 index 0000000000..b7a022463d --- /dev/null +++ b/pkgs/oauth2/lib/src/client_authenticator.dart @@ -0,0 +1,10 @@ +// Copyright (c) 2025, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; + +/// A callback used to add additional client authentication headers or body +/// parameters to a token request (e.g., for JWT assertions per RFC 7523). +typedef ClientAuthenticator = FutureOr Function( + Map headers, Map body); diff --git a/pkgs/oauth2/lib/src/client_credentials_grant.dart b/pkgs/oauth2/lib/src/client_credentials_grant.dart index 045d1a086e..56afe58866 100644 --- a/pkgs/oauth2/lib/src/client_credentials_grant.dart +++ b/pkgs/oauth2/lib/src/client_credentials_grant.dart @@ -8,6 +8,7 @@ import 'package:http/http.dart' as http; import 'package:http_parser/http_parser.dart'; import 'client.dart'; +import 'client_authenticator.dart'; import 'handle_access_token_response.dart'; import 'utils.dart'; @@ -38,13 +39,22 @@ import 'utils.dart'; /// /// This function is passed the `Content-Type` header of the response as well as /// its body as a UTF-8-decoded string. It should return a map in the same -/// format as the [standard JSON response](https://tools.ietf.org/html/rfc6749#section-5.1) +/// format as the [standard JSON response](https://tools.ietf.org/html/rfc6749#section-5.1). +/// +/// [customAuth] is an optional callback to add additional client +/// authentication headers or body parameters to a token request for advanced +/// scenarios, such as when using a JWT Bearer token for client authentication +/// per [RFC 7523](https://tools.ietf.org/html/rfc7523#section-2.2). When +/// provided, it replaces the default `basicAuth` credentials integration in +/// token requests. Future clientCredentialsGrant( Uri authorizationEndpoint, String? identifier, String? secret, {Iterable? scopes, bool basicAuth = true, http.Client? httpClient, String? delimiter, + ClientAuthenticator? customAuth, + Iterable? resources, Map Function(MediaType? contentType, String body)? getParameters}) async { delimiter ??= ' '; @@ -54,7 +64,10 @@ Future clientCredentialsGrant( var headers = {}; - if (identifier != null) { + if (customAuth != null) { + if (identifier != null) body['client_id'] = identifier; + await customAuth(headers, body); + } else if (identifier != null) { if (basicAuth) { headers['Authorization'] = basicAuthHeader(identifier, secret!); } else { @@ -67,6 +80,34 @@ Future clientCredentialsGrant( body['scope'] = scopes.join(delimiter); } + if (resources != null && resources.isNotEmpty) { + // http.post doesn't support Map for x-www-form-urlencoded + // bodies, so we construct the body string manually to allow + // multiple 'resource' parameters per RFC 8707. + final encodedBody = body.entries + .map((e) => '${Uri.encodeQueryComponent(e.key)}=' + '${Uri.encodeQueryComponent(e.value)}') + .toList(); + for (final r in resources) { + encodedBody.add('resource=${Uri.encodeQueryComponent(r.toString())}'); + } + + httpClient ??= http.Client(); + var response = await httpClient.post(authorizationEndpoint, + headers: headers + ..['content-type'] = 'application/x-www-form-urlencoded', + body: encodedBody.join('&')); + + var credentials = handleAccessTokenResponse(response, authorizationEndpoint, + startTime, scopes?.toList() ?? [], delimiter, + getParameters: getParameters); + return Client(credentials, + identifier: identifier, + secret: secret, + httpClient: httpClient, + customAuth: customAuth); + } + httpClient ??= http.Client(); var response = await httpClient.post(authorizationEndpoint, headers: headers, body: body); @@ -75,5 +116,8 @@ Future clientCredentialsGrant( startTime, scopes?.toList() ?? [], delimiter, getParameters: getParameters); return Client(credentials, - identifier: identifier, secret: secret, httpClient: httpClient); + identifier: identifier, + secret: secret, + httpClient: httpClient, + customAuth: customAuth); } diff --git a/pkgs/oauth2/lib/src/credentials.dart b/pkgs/oauth2/lib/src/credentials.dart index 088b482fbf..b939f24647 100644 --- a/pkgs/oauth2/lib/src/credentials.dart +++ b/pkgs/oauth2/lib/src/credentials.dart @@ -9,6 +9,7 @@ import 'dart:convert'; import 'package:http/http.dart' as http; import 'package:http_parser/http_parser.dart'; +import 'client_authenticator.dart'; import 'handle_access_token_response.dart'; import 'parameters.dart'; import 'utils.dart'; @@ -207,6 +208,13 @@ class Credentials { /// You may request different scopes than the default by passing in /// [newScopes]. These must be a subset of [scopes]. /// + /// [customAuth] is an optional callback to add additional client + /// authentication headers or body parameters to a token request for advanced + /// scenarios, such as when using a JWT Bearer token for client authentication + /// per [RFC 7523](https://tools.ietf.org/html/rfc7523#section-2.2). When + /// provided, it replaces the default `basicAuth` credentials integration in + /// token requests. + /// /// This throws an [ArgumentError] if [secret] is passed without [identifier], /// a [StateError] if these credentials can't be refreshed, an /// [AuthorizationException] if refreshing the credentials fails, or a @@ -216,6 +224,8 @@ class Credentials { String? secret, Iterable? newScopes, bool basicAuth = true, + ClientAuthenticator? customAuth, + Iterable? resources, http.Client? httpClient}) async { var scopes = this.scopes; if (newScopes != null) scopes = newScopes.toList(); @@ -238,16 +248,44 @@ class Credentials { var headers = {}; - var body = {'grant_type': 'refresh_token', 'refresh_token': refreshToken}; + var body = { + 'grant_type': 'refresh_token', + 'refresh_token': refreshToken!, + }; if (scopes.isNotEmpty) body['scope'] = scopes.join(_delimiter); - - if (basicAuth && secret != null) { + if (customAuth != null) { + if (identifier != null) body['client_id'] = identifier; + await customAuth(headers, body); + } else if (basicAuth && secret != null) { headers['Authorization'] = basicAuthHeader(identifier!, secret); } else { if (identifier != null) body['client_id'] = identifier; if (secret != null) body['client_secret'] = secret; } + if (resources != null && resources.isNotEmpty) { + final encodedBody = body.entries + .map((e) => '${Uri.encodeQueryComponent(e.key)}=' + '${Uri.encodeQueryComponent(e.value)}') + .toList(); + for (final r in resources) { + encodedBody.add('resource=${Uri.encodeQueryComponent(r.toString())}'); + } + headers['content-type'] = 'application/x-www-form-urlencoded'; + var response = await httpClient.post(tokenEndpoint, + headers: headers, body: encodedBody.join('&')); + var credentials = handleAccessTokenResponse( + response, tokenEndpoint, startTime, scopes, _delimiter, + getParameters: _getParameters); + if (credentials.refreshToken != null) return credentials; + return Credentials(credentials.accessToken, + refreshToken: refreshToken, + idToken: credentials.idToken, + tokenEndpoint: credentials.tokenEndpoint, + scopes: credentials.scopes, + expiration: credentials.expiration); + } + var response = await httpClient.post(tokenEndpoint, headers: headers, body: body); var credentials = handleAccessTokenResponse( diff --git a/pkgs/oauth2/lib/src/discovery.dart b/pkgs/oauth2/lib/src/discovery.dart new file mode 100644 index 0000000000..ee5d765e64 --- /dev/null +++ b/pkgs/oauth2/lib/src/discovery.dart @@ -0,0 +1,296 @@ +// Copyright (c) 2025, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +/// An exception thrown when OAuth 2.0 discovery fails. +class DiscoveryException implements Exception { + final String message; + + DiscoveryException(this.message); + + @override + String toString() => message; +} + +/// OAuth 2.0 Authorization Server Metadata (RFC 8414). +final class OAuthServerMetadata { + /// The authorization server's issuer identifier. + /// + /// This is a URL that uniquely identifies the authorization server. It is + /// typically used as a base URL for other endpoints and to prevent + /// mix-up attacks. + final String issuer; + + /// URL of the authorization server's authorization endpoint. + /// + /// This is the URL to which the user should be redirected to begin the + /// authorization process. + final String authorizationEndpoint; + + /// URL of the authorization server's token endpoint. + /// + /// This is the URL where the client exchanges an authorization grant (like + /// an authorization code) for an access token. + final String tokenEndpoint; + + /// URL of the authorization server's OAuth 2.0 Dynamic Client Registration + /// endpoint. + /// + /// This is used by clients to dynamically register with the authorization + /// server to obtain a client ID and optionally a client secret. + final String? registrationEndpoint; + + /// JSON array containing a list of the OAuth 2.0 scope values that this + /// authorization server supports. + /// + /// This allows clients to know in advance which scopes they can request. + final List? scopesSupported; + + /// JSON array containing a list of the OAuth 2.0 response type values + /// that this authorization server supports. + /// + /// This indicates which authorization flows (e.g., "code", "token") are + /// available. + final List responseTypesSupported; + + /// JSON array containing a list of the OAuth 2.0 grant type values that this + /// authorization server supports. + /// + /// This informs clients about the supported methods for obtaining a token + /// (e.g., "authorization_code", "client_credentials"). + final List? grantTypesSupported; + + /// JSON array containing a list of client authentication methods supported + /// by this token endpoint. + /// + /// This specifies how the client should authenticate itself when requesting + /// a token (e.g., "client_secret_basic", "client_secret_post"). + final List? tokenEndpointAuthMethodsSupported; + + /// JSON array containing a list of PKCE code challenge methods supported + /// by this authorization server. + /// + /// This lists the supported hashing algorithms for Proof Key for Code + /// Exchange (e.g., "S256", "plain"). + final List? codeChallengeMethodsSupported; + + /// Boolean value specifying whether the authorization server supports + /// multiple issuers. + /// + /// If true, the server can issue tokens for multiple issuers, which might + /// require additional verification steps by the client. + final bool? clientIdMetadataDocumentSupported; + + const OAuthServerMetadata({ + required this.issuer, + required this.authorizationEndpoint, + required this.tokenEndpoint, + this.registrationEndpoint, + this.scopesSupported, + required this.responseTypesSupported, + this.grantTypesSupported, + this.tokenEndpointAuthMethodsSupported, + this.codeChallengeMethodsSupported, + this.clientIdMetadataDocumentSupported, + }); + + factory OAuthServerMetadata.fromJson(Map json) { + return OAuthServerMetadata( + issuer: json['issuer'] as String, + authorizationEndpoint: json['authorization_endpoint'] as String, + tokenEndpoint: json['token_endpoint'] as String, + registrationEndpoint: json['registration_endpoint'] as String?, + scopesSupported: _stringList(json['scopes_supported']), + responseTypesSupported: + _stringList(json['response_types_supported']) ?? const [], + grantTypesSupported: _stringList(json['grant_types_supported']), + tokenEndpointAuthMethodsSupported: _stringList( + json['token_endpoint_auth_methods_supported'], + ), + codeChallengeMethodsSupported: _stringList( + json['code_challenge_methods_supported'], + ), + clientIdMetadataDocumentSupported: + json['client_id_metadata_document_supported'] as bool?, + ); + } +} + +/// OAuth 2.0 Protected Resource Metadata (RFC 9728). +final class OAuthProtectedResourceMetadata { + /// A URI that identifies the protected resource. + /// + /// This is used to prevent mix-up and spoofing attacks by ensuring the + /// metadata corresponds to the intended resource server. + final String resource; + + /// JSON array of authorization server identifiers that the protected resource + /// trusts. + /// + /// This tells clients which authorization servers they can use to obtain + /// access tokens for this resource. + final List? authorizationServers; + + /// JSON array of the scope values that the protected resource supports. + /// + /// This helps clients understand what level of access they can request + /// for this specific resource. + final List? scopesSupported; + + const OAuthProtectedResourceMetadata({ + required this.resource, + this.authorizationServers, + this.scopesSupported, + }); + + factory OAuthProtectedResourceMetadata.fromJson(Map json) { + return OAuthProtectedResourceMetadata( + resource: json['resource'] as String, + authorizationServers: _stringList(json['authorization_servers']), + scopesSupported: _stringList(json['scopes_supported']), + ); + } +} + +List? _stringList(dynamic value) { + if (value is! List) return null; + return value.whereType().toList(); +} + +/// Discovers OAuth 2.0 / OpenID Connect authorization server metadata. +/// +/// Tries RFC 8414 (`oauth-authorization-server`) first, then falls back to +/// OpenID Connect Discovery (`openid-configuration`). +/// +/// Completes with the metadata, or with `null` if no metadata endpoint could +/// be found. +/// +/// If a metadata endpoint is found but returns an invalid response (e.g., +/// malformed JSON or issuer spoofing is detected), the function will catch +/// the resulting exception and automatically try the next fallback URL. +/// It throws a [DiscoveryException] or [FormatException] only if all attempted +/// endpoints fail with errors. +Future discoverAuthorizationServerMetadata( + Uri authorizationServerUrl, { + http.Client? httpClient, +}) async { + if (!authorizationServerUrl.isScheme('https')) { + throw ArgumentError.value(authorizationServerUrl, 'authorizationServerUrl', + 'Must be an HTTPS URL per RFC 8414.'); + } + final client = httpClient ?? http.Client(); + try { + for (final endpoint in _buildDiscoveryUrls(authorizationServerUrl)) { + try { + final response = await client.get(endpoint); + if (response.statusCode < 200 || response.statusCode >= 300) { + if (response.statusCode >= 400 && response.statusCode < 500) { + continue; + } + throw DiscoveryException( + 'HTTP ${response.statusCode} loading authorization server ' + 'metadata from $endpoint', + ); + } + final metadata = OAuthServerMetadata.fromJson( + jsonDecode(response.body) as Map, + ); + + final expectedIssuer = authorizationServerUrl.toString(); + if (metadata.issuer.replaceAll(RegExp(r'/$'), '') != + expectedIssuer.replaceAll(RegExp(r'/$'), '')) { + throw DiscoveryException( + 'Issuer spoofing detected: metadata issuer "${metadata.issuer}" ' + 'does not match expected "$expectedIssuer".', + ); + } + return metadata; + } catch (e) { + if (e is DiscoveryException) rethrow; + continue; + } + } + return null; + } finally { + if (httpClient == null) client.close(); + } +} + +/// Discovers RFC 9728 OAuth 2.0 Protected Resource Metadata. +/// +/// The returned [Future] completes with the metadata. It completes with a +/// [DiscoveryException] if the metadata endpoint is not found (HTTP 404) or +/// returns another invalid response. +Future discoverProtectedResourceMetadata( + Uri serverUrl, { + Uri? resourceMetadataUrl, + http.Client? httpClient, +}) async { + if (!serverUrl.isScheme('https')) { + throw ArgumentError.value( + serverUrl, 'serverUrl', 'Must be an HTTPS URL per RFC 9728.'); + } + if (resourceMetadataUrl != null && !resourceMetadataUrl.isScheme('https')) { + throw ArgumentError.value(resourceMetadataUrl, 'resourceMetadataUrl', + 'Must be an HTTPS URL per RFC 9728.'); + } + final client = httpClient ?? http.Client(); + try { + final url = resourceMetadataUrl ?? + serverUrl.replace( + path: '/.well-known/oauth-protected-resource${serverUrl.path}', + ); + + final response = await client.get(url); + if (response.statusCode == 404) { + throw DiscoveryException( + 'Resource server does not implement OAuth 2.0 Protected Resource ' + 'Metadata.', + ); + } + if (response.statusCode < 200 || response.statusCode >= 300) { + throw DiscoveryException( + 'HTTP ${response.statusCode} loading protected resource metadata.', + ); + } + final metadata = OAuthProtectedResourceMetadata.fromJson( + jsonDecode(response.body) as Map, + ); + + final expectedResource = serverUrl.toString(); + if (metadata.resource.replaceAll(RegExp(r'/$'), '') != + expectedResource.replaceAll(RegExp(r'/$'), '')) { + throw DiscoveryException( + 'Resource spoofing detected: metadata resource "${metadata.resource}" ' + 'does not match expected "$expectedResource".', + ); + } + return metadata; + } finally { + if (httpClient == null) client.close(); + } +} + +List _buildDiscoveryUrls(Uri authServerUrl) { + final hasPath = authServerUrl.path.isNotEmpty && authServerUrl.path != '/'; + final origin = authServerUrl.origin; + + if (!hasPath) { + return [ + Uri.parse('$origin/.well-known/oauth-authorization-server'), + Uri.parse('$origin/.well-known/openid-configuration'), + ]; + } + + var path = authServerUrl.path; + if (path.endsWith('/')) path = path.substring(0, path.length - 1); + return [ + Uri.parse('$origin/.well-known/oauth-authorization-server$path'), + Uri.parse('$origin/.well-known/openid-configuration$path'), + Uri.parse('$origin$path/.well-known/openid-configuration'), + ]; +} diff --git a/pkgs/oauth2/lib/src/registration.dart b/pkgs/oauth2/lib/src/registration.dart new file mode 100644 index 0000000000..0d3b5f1d1a --- /dev/null +++ b/pkgs/oauth2/lib/src/registration.dart @@ -0,0 +1,221 @@ +// Copyright (c) 2025, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +import 'authorization_exception.dart'; +import 'discovery.dart'; + +/// OAuth 2.0 Client Metadata ([RFC 7591]). +/// +/// [RFC 7591]: https://datatracker.ietf.org/doc/html/rfc7591 +final class OAuthClientMetadata { + /// Array of redirection URI strings for use in redirect-based flows. + /// + /// After the user authorizes the client, the authorization server will + /// redirect the user back to one of these URIs. + final List redirectUris; + + /// String indicator of the requested authentication method for the token + /// endpoint. + /// + /// This informs the authorization server how the client intends to + /// authenticate itself (e.g., "client_secret_basic", "none"). + final String? tokenEndpointAuthMethod; + + /// Array of OAuth 2.0 grant type strings that the client can use at the + /// token endpoint. + /// + /// This restricts the client to using only the specified grant types + /// (e.g., "authorization_code", "refresh_token"). + final List? grantTypes; + + /// Array of the OAuth 2.0 response type strings that the client can use at + /// the authorization endpoint. + /// + /// This restricts the client to initiating only specific authorization flows + /// (e.g., "code"). + final List? responseTypes; + + /// Human-readable string name of the client to be presented to the end-user. + /// + /// This is typically displayed on the authorization consent screen so the + /// user knows which application is requesting access. + final String? clientName; + + /// URL string of a web page providing information about the client. + /// + /// This provides a link on the authorization consent screen where the user + /// can learn more about the client. + final String? clientUri; + + /// String containing a space-separated list of scope values that the client + /// can use when requesting access tokens. + /// + /// This defines the default or maximum set of permissions the client + /// is allowed to request. + final String? scope; + + /// A unique identifier string assigned by the client developer or + /// software publisher used by registration endpoints. + /// + /// This helps the authorization server categorize or identify the specific + /// software application being registered. + final String? softwareId; + + /// A version identifier string for the client software identified by + /// [softwareId]. + /// + /// This allows the authorization server to track the specific version of + /// the software being registered. + final String? softwareVersion; + + const OAuthClientMetadata({ + required this.redirectUris, + this.tokenEndpointAuthMethod, + this.grantTypes, + this.responseTypes, + this.clientName, + this.clientUri, + this.scope, + this.softwareId, + this.softwareVersion, + }); + + Map toJson() { + return { + 'redirect_uris': redirectUris, + if (tokenEndpointAuthMethod != null) + 'token_endpoint_auth_method': tokenEndpointAuthMethod, + if (grantTypes != null) 'grant_types': grantTypes, + if (responseTypes != null) 'response_types': responseTypes, + if (clientName != null) 'client_name': clientName, + if (clientUri != null) 'client_uri': clientUri, + if (scope != null) 'scope': scope, + if (softwareId != null) 'software_id': softwareId, + if (softwareVersion != null) 'software_version': softwareVersion, + }; + } +} + +/// OAuth 2.0 Client Information ([RFC 7591]). +/// +/// [RFC 7591]: https://datatracker.ietf.org/doc/html/rfc7591#section-3.2.1 +final class OAuthClientInformation { + /// Opaque value used by the client to identify itself to the authorization + /// server. + /// + /// This is required for all requests to the token endpoint to identify + /// the client. + final String clientId; + + /// String value specifying the client secret. + /// + /// This acts as a password for the client to authenticate itself with + /// the authorization server. It must be kept confidential. + final String? clientSecret; + + /// Time at which the client identifier was issued. + /// + /// Given as seconds since epoch. This helps determine the age of the + /// registration. + final int? clientIdIssuedAt; + + /// Time at which the client secret will expire or `0` if it will not expire. + /// + /// Given as seconds since epoch. This allows the client to know when it + /// needs to register a new secret. + final int? clientSecretExpiresAt; + + /// String indicator of the authentication method that the authorization + /// server will accept from the client when using the token endpoint. + /// + /// This confirms the authentication method the client must use, which may + /// or may not be what it requested. + final String? tokenEndpointAuthMethod; + + const OAuthClientInformation({ + required this.clientId, + this.clientSecret, + this.clientIdIssuedAt, + this.clientSecretExpiresAt, + this.tokenEndpointAuthMethod, + }); + + factory OAuthClientInformation.fromJson(Map json) { + return OAuthClientInformation( + clientId: json['client_id'] as String, + clientSecret: json['client_secret'] as String?, + clientIdIssuedAt: json['client_id_issued_at'] as int?, + clientSecretExpiresAt: json['client_secret_expires_at'] as int?, + tokenEndpointAuthMethod: json['token_endpoint_auth_method'] as String?, + ); + } +} + +/// Performs RFC 7591 Dynamic Client Registration. +/// +/// Dynamic Client Registration allows OAuth 2.0 clients to register with an +/// authorization server dynamically and obtain client credentials (such as +/// a client ID and client secret) required to interact with the server. +/// +/// The returned [Future] completes with the client information. It will +/// complete with an [AuthorizationException] if the request is rejected by the +/// server. +Future registerClient( + Uri authorizationServerUrl, + OAuthClientMetadata clientMetadata, { + OAuthServerMetadata? metadata, + http.Client? httpClient, +}) async { + if (!authorizationServerUrl.isScheme('https')) { + throw ArgumentError.value(authorizationServerUrl, 'authorizationServerUrl', + 'Must be an HTTPS URL per RFC 7591.'); + } + + final client = httpClient ?? http.Client(); + try { + final endpoint = metadata?.registrationEndpoint; + Uri registrationUrl; + if (endpoint != null) { + registrationUrl = Uri.parse(endpoint); + } else { + registrationUrl = authorizationServerUrl.replace(path: '/register'); + } + + if (!registrationUrl.isScheme('https')) { + throw ArgumentError.value(registrationUrl, 'registrationEndpoint', + 'Must be an HTTPS URL per RFC 7591.'); + } + + final response = await client.post( + registrationUrl, + headers: {'content-type': 'application/json'}, + body: jsonEncode(clientMetadata.toJson()), + ); + + if (response.statusCode < 200 || response.statusCode >= 300) { + if (response.headers['content-type']?.contains('application/json') == + true) { + final body = jsonDecode(response.body) as Map; + var description = body['error_description'] as String?; + var uriString = body['error_uri'] as String?; + var uri = uriString == null ? null : Uri.parse(uriString); + throw AuthorizationException(body['error'] as String, description, uri); + } + throw AuthorizationException( + 'server_error', + 'HTTP ${response.statusCode} registering client at $registrationUrl', + null, + ); + } + return OAuthClientInformation.fromJson( + jsonDecode(response.body) as Map, + ); + } finally { + if (httpClient == null) client.close(); + } +} diff --git a/pkgs/oauth2/lib/src/utils.dart b/pkgs/oauth2/lib/src/utils.dart index 2a22b9fa5a..d48649b6f5 100644 --- a/pkgs/oauth2/lib/src/utils.dart +++ b/pkgs/oauth2/lib/src/utils.dart @@ -6,7 +6,7 @@ import 'dart:convert'; /// Adds additional query parameters to [url], overwriting the original /// parameters if a name conflict occurs. -Uri addQueryParameters(Uri url, Map parameters) => url.replace( +Uri addQueryParameters(Uri url, Map parameters) => url.replace( queryParameters: Map.from(url.queryParameters)..addAll(parameters)); String basicAuthHeader(String identifier, String secret) { diff --git a/pkgs/oauth2/pubspec.yaml b/pkgs/oauth2/pubspec.yaml index b60d537a07..7ae2230f00 100644 --- a/pkgs/oauth2/pubspec.yaml +++ b/pkgs/oauth2/pubspec.yaml @@ -1,5 +1,5 @@ name: oauth2 -version: 2.0.5 +version: 2.1.0 description: >- A client library for authenticating with a remote service via OAuth2 on behalf of a user, and making authorized HTTP requests with the user's diff --git a/pkgs/oauth2/test/discovery_test.dart b/pkgs/oauth2/test/discovery_test.dart new file mode 100644 index 0000000000..c2a0d64b3e --- /dev/null +++ b/pkgs/oauth2/test/discovery_test.dart @@ -0,0 +1,222 @@ +// Copyright (c) 2025, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:convert'; + +import 'package:http/http.dart' as http; +import 'package:oauth2/src/discovery.dart'; +import 'package:test/test.dart'; + +import 'utils.dart'; + +void main() { + group('discoverAuthorizationServerMetadata', () { + test('discovers metadata using RFC 8414 well-known URI', () async { + var client = ExpectClient() + ..expectRequest((request) { + expect( + request.url.toString(), + equals( + 'https://server.example.com/.well-known/oauth-authorization-server')); + + return Future.value(http.Response( + jsonEncode({ + 'issuer': 'https://server.example.com', + 'authorization_endpoint': 'https://server.example.com/auth', + 'token_endpoint': 'https://server.example.com/token', + 'response_types_supported': ['code'], + }), + 200, + headers: {'content-type': 'application/json'})); + }); + + var metadata = await discoverAuthorizationServerMetadata( + Uri.parse('https://server.example.com'), + httpClient: client); + + expect(metadata, isNotNull); + expect(metadata!.issuer, equals('https://server.example.com')); + expect(metadata.authorizationEndpoint, + equals('https://server.example.com/auth')); + expect( + metadata.tokenEndpoint, equals('https://server.example.com/token')); + expect(metadata.responseTypesSupported, equals(['code'])); + }); + + test('falls back to OpenID Connect Discovery URI', () async { + var client = ExpectClient() + ..expectRequest((request) { + expect( + request.url.toString(), + equals( + 'https://server.example.com/.well-known/oauth-authorization-server')); + return Future.value(http.Response('', 404)); + }) + ..expectRequest((request) { + expect( + request.url.toString(), + equals( + 'https://server.example.com/.well-known/openid-configuration')); + + return Future.value(http.Response( + jsonEncode({ + 'issuer': 'https://server.example.com', + 'authorization_endpoint': 'https://server.example.com/auth', + 'token_endpoint': 'https://server.example.com/token', + 'response_types_supported': ['code'], + }), + 200, + headers: {'content-type': 'application/json'})); + }); + + var metadata = await discoverAuthorizationServerMetadata( + Uri.parse('https://server.example.com'), + httpClient: client); + + expect(metadata, isNotNull); + }); + + test('throws DiscoveryException on issuer mismatch', () async { + var client = ExpectClient() + ..expectRequest((request) { + return Future.value(http.Response( + jsonEncode({ + 'issuer': 'https://malicious.example.com', + 'authorization_endpoint': 'https://server.example.com/auth', + 'token_endpoint': 'https://server.example.com/token', + 'response_types_supported': ['code'], + }), + 200, + headers: {'content-type': 'application/json'})); + }); + + expect( + discoverAuthorizationServerMetadata( + Uri.parse('https://server.example.com'), + httpClient: client), + throwsA(isA())); + }); + + test('throws DiscoveryException on unexpected error', () async { + var client = ExpectClient() + ..expectRequest((request) { + return Future.value(http.Response('', 500)); + }); + + expect( + discoverAuthorizationServerMetadata( + Uri.parse('https://server.example.com'), + httpClient: client), + throwsA(isA())); + }); + + test('throws ArgumentError on insecure URL', () async { + expect( + () => discoverAuthorizationServerMetadata( + Uri.parse('http://server.example.com')), + throwsArgumentError); + }); + }); + + group('discoverProtectedResourceMetadata', () { + test('discovers metadata using RFC 9728 well-known URI', () async { + var client = ExpectClient() + ..expectRequest((request) { + expect( + request.url.toString(), + equals( + 'https://resource.example.com/.well-known/oauth-protected-resource')); + + return Future.value(http.Response( + jsonEncode({ + 'resource': 'https://resource.example.com', + 'authorization_servers': ['https://server.example.com'], + 'scopes_supported': ['read', 'write'], + }), + 200, + headers: {'content-type': 'application/json'})); + }); + + var metadata = await discoverProtectedResourceMetadata( + Uri.parse('https://resource.example.com'), + httpClient: client); + + expect(metadata.resource, equals('https://resource.example.com')); + expect(metadata.authorizationServers, + equals(['https://server.example.com'])); + expect(metadata.scopesSupported, equals(['read', 'write'])); + }); + + test('discovers metadata with path component', () async { + var client = ExpectClient() + ..expectRequest((request) { + expect( + request.url.toString(), + equals( + 'https://resource.example.com/.well-known/oauth-protected-resource/v1/api')); + + return Future.value(http.Response( + jsonEncode({ + 'resource': 'https://resource.example.com/v1/api', + 'authorization_servers': ['https://server.example.com'], + }), + 200, + headers: {'content-type': 'application/json'})); + }); + + var metadata = await discoverProtectedResourceMetadata( + Uri.parse('https://resource.example.com/v1/api'), + httpClient: client); + + expect(metadata.resource, equals('https://resource.example.com/v1/api')); + }); + + test('throws DiscoveryException on resource mismatch', () async { + var client = ExpectClient() + ..expectRequest((request) { + return Future.value(http.Response( + jsonEncode({ + 'resource': 'https://malicious-resource.example.com', + 'authorization_servers': ['https://server.example.com'], + }), + 200, + headers: {'content-type': 'application/json'})); + }); + + expect( + discoverProtectedResourceMetadata( + Uri.parse('https://resource.example.com'), + httpClient: client), + throwsA(isA())); + }); + + test('throws DiscoveryException for 404', () async { + var client = ExpectClient() + ..expectRequest((request) { + return Future.value(http.Response('', 404)); + }); + + expect( + discoverProtectedResourceMetadata( + Uri.parse('https://resource.example.com'), + httpClient: client), + throwsA(isA())); + }); + + test('throws ArgumentError on insecure URL', () async { + expect( + () => discoverProtectedResourceMetadata( + Uri.parse('http://resource.example.com')), + throwsArgumentError); + }); + + test('throws ArgumentError on insecure resourceMetadataUrl', () async { + expect( + () => discoverProtectedResourceMetadata( + Uri.parse('https://resource.example.com'), + resourceMetadataUrl: Uri.parse('http://malicious.example.com')), + throwsArgumentError); + }); + }); +} diff --git a/pkgs/oauth2/test/registration_test.dart b/pkgs/oauth2/test/registration_test.dart new file mode 100644 index 0000000000..67343ce759 --- /dev/null +++ b/pkgs/oauth2/test/registration_test.dart @@ -0,0 +1,146 @@ +// Copyright (c) 2025, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:convert'; + +import 'package:http/http.dart' as http; +import 'package:oauth2/src/authorization_exception.dart'; +import 'package:oauth2/src/discovery.dart'; +import 'package:oauth2/src/registration.dart'; +import 'package:test/test.dart'; + +import 'utils.dart'; + +void main() { + group('registerClient', () { + test('registers successfully with authorization server URL', () async { + var client = ExpectClient() + ..expectRequest((request) { + expect(request.url.toString(), + equals('https://server.example.com/register')); + expect(request.headers['content-type'], equals('application/json')); + + var body = jsonDecode(request.body) as Map; + expect(body['client_name'], equals('My App')); + expect(body['redirect_uris'], equals(['app://callback'])); + + return Future.value(http.Response( + jsonEncode({ + 'client_id': 's6BhdRkqt3', + 'client_secret': 'cf4c9Z7hO3', + 'client_id_issued_at': 2893256800, + 'client_secret_expires_at': 2893276800, + }), + 201, + headers: {'content-type': 'application/json'})); + }); + + var metadata = const OAuthClientMetadata( + clientName: 'My App', redirectUris: ['app://callback']); + var info = await registerClient( + Uri.parse('https://server.example.com'), metadata, + httpClient: client); + + expect(info.clientId, equals('s6BhdRkqt3')); + expect(info.clientSecret, equals('cf4c9Z7hO3')); + expect(info.clientIdIssuedAt, equals(2893256800)); + expect(info.clientSecretExpiresAt, equals(2893276800)); + }); + + test('registers successfully using metadata registration_endpoint', + () async { + var client = ExpectClient() + ..expectRequest((request) { + expect(request.url.toString(), + equals('https://server.example.com/api/v1/register')); + + return Future.value(http.Response( + jsonEncode({ + 'client_id': 's6BhdRkqt3', + }), + 201, + headers: {'content-type': 'application/json'})); + }); + + var serverMetadata = const OAuthServerMetadata( + issuer: 'https://server.example.com', + authorizationEndpoint: 'https://server.example.com/auth', + tokenEndpoint: 'https://server.example.com/token', + responseTypesSupported: ['code'], + registrationEndpoint: 'https://server.example.com/api/v1/register'); + + var metadata = + const OAuthClientMetadata(redirectUris: ['app://callback']); + var info = await registerClient( + Uri.parse('https://server.example.com'), metadata, + metadata: serverMetadata, httpClient: client); + + expect(info.clientId, equals('s6BhdRkqt3')); + }); + + test('throws AuthorizationException on error response', () async { + var client = ExpectClient() + ..expectRequest((request) { + return Future.value(http.Response( + jsonEncode({ + 'error': 'invalid_redirect_uri', + 'error_description': 'The redirect_uri is not allowed.' + }), + 400, + headers: {'content-type': 'application/json'})); + }); + + var metadata = + const OAuthClientMetadata(redirectUris: ['app://callback']); + + expect( + registerClient(Uri.parse('https://server.example.com'), metadata, + httpClient: client), + throwsA(isA().having( + (e) => e.error, 'error', equals('invalid_redirect_uri')))); + }); + + test('throws AuthorizationException on unexpected status code without JSON', + () async { + var client = ExpectClient() + ..expectRequest((request) { + return Future.value(http.Response('Internal Server Error', 500)); + }); + + var metadata = + const OAuthClientMetadata(redirectUris: ['app://callback']); + + expect( + registerClient(Uri.parse('https://server.example.com'), metadata, + httpClient: client), + throwsA(isA())); + }); + + test('throws ArgumentError on insecure authorizationServerUrl', () async { + var metadata = + const OAuthClientMetadata(redirectUris: ['app://callback']); + expect( + () => + registerClient(Uri.parse('http://server.example.com'), metadata), + throwsArgumentError); + }); + + test('throws ArgumentError on insecure metadata registration_endpoint', + () async { + var serverMetadata = const OAuthServerMetadata( + issuer: 'https://server.example.com', + authorizationEndpoint: 'https://server.example.com/auth', + tokenEndpoint: 'https://server.example.com/token', + responseTypesSupported: ['code'], + registrationEndpoint: 'http://server.example.com/api/v1/register'); + var metadata = + const OAuthClientMetadata(redirectUris: ['app://callback']); + expect( + () => registerClient( + Uri.parse('https://server.example.com'), metadata, + metadata: serverMetadata), + throwsArgumentError); + }); + }); +}