Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
e4d905b
feat: add support for OAuth 2.0 discovery, registration, resource ind…
pavelgj Mar 3, 2026
8ef3bae
fix: Add issuer validation to OAuth2 discovery to prevent spoofing an…
pavelgj Mar 3, 2026
dde8900
feat: add support for multiple resource parameters per RFC 8707 in va…
pavelgj Mar 3, 2026
e677291
feat: Add resource spoofing detection to protected resource metadata …
pavelgj Mar 3, 2026
14a8598
updated changelog
pavelgj Mar 3, 2026
9a3caca
feat: enforce HTTPS scheme for discovery and registration URLs and ad…
pavelgj Mar 3, 2026
a350fd6
docs: Add documentation comments to fields in OAuth metadata and info…
pavelgj Mar 3, 2026
20dcbb4
feat: enforce HTTPS for protected resource metadata URLs and update d…
pavelgj Mar 3, 2026
7c92a10
fix: Normalize issuer and resource URLs by removing trailing slashes …
pavelgj Mar 3, 2026
2922351
Update pkgs/oauth2/lib/src/registration.dart
pavelgj Mar 3, 2026
43496a3
Update pkgs/oauth2/lib/src/registration.dart
pavelgj Mar 3, 2026
256511e
Update pkgs/oauth2/lib/src/registration.dart
pavelgj Mar 3, 2026
871863d
Update pkgs/oauth2/lib/src/registration.dart
pavelgj Mar 3, 2026
0b8cc35
Update pkgs/oauth2/lib/src/registration.dart
pavelgj Mar 3, 2026
c48ab45
Update pkgs/oauth2/lib/src/registration.dart
pavelgj Mar 3, 2026
912af44
Update pkgs/oauth2/lib/src/registration.dart
pavelgj Mar 3, 2026
92776cc
feedback
pavelgj Mar 3, 2026
86adda1
refactor: Simplify `addQueryParameters` and document potential except…
pavelgj Mar 5, 2026
75c2f99
refactor: update addQueryParameters to use `url.queryParameters` and …
pavelgj Mar 5, 2026
cc61861
docs: Add Javadoc comments to OAuth discovery and registration metada…
pavelgj Mar 5, 2026
219d352
Merge branch 'main' into pj/discovery
pavelgj Mar 5, 2026
bddf7df
fmt
pavelgj Mar 5, 2026
907b0f6
docs: Update `discoverAuthorizationServerMetadata` comments to clarif…
pavelgj Mar 5, 2026
6d30cb1
Merge branch 'main' into pj/discovery
mosuem Jun 22, 2026
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
3 changes: 3 additions & 0 deletions pkgs/oauth2/lib/oauth2.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
27 changes: 20 additions & 7 deletions pkgs/oauth2/lib/src/authorization_code_grant.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -155,12 +159,14 @@ class AuthorizationCodeGrant {
CredentialsRefreshedCallback? onCredentialsRefreshed,
Map<String, dynamic> 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
Expand All @@ -183,7 +189,7 @@ class AuthorizationCodeGrant {
///
/// It is a [StateError] to call this more than once.
Uri getAuthorizationUrl(Uri redirect,
{Iterable<String>? scopes, String? state}) {
{Iterable<String>? scopes, String? state, Iterable<Uri>? resources}) {
Comment on lines 198 to +199

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The new resources parameter in getAuthorizationUrl is not documented. According to the repository's style guide, all public members should be documented. Please add documentation for this new parameter to explain its purpose, which is for RFC 8707 Resource Indicators.

References
  1. At least all public members should have documentation, answering the why. (link)

if (_state != _State.initial) {
throw StateError('The authorization URL has already been generated.');
}
Expand All @@ -197,7 +203,7 @@ class AuthorizationCodeGrant {
_redirectEndpoint = redirect;
_scopes = scopeList;
_stateString = state;
var parameters = {
var parameters = <String, dynamic>{
'response_type': 'code',
'client_id': identifier,
'redirect_uri': redirect.toString(),
Expand All @@ -207,6 +213,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);
}
Expand Down Expand Up @@ -297,15 +306,18 @@ class AuthorizationCodeGrant {

var headers = <String, String>{};

var body = {
var body = <String, String>{
'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
Expand All @@ -325,6 +337,7 @@ class AuthorizationCodeGrant {
secret: secret,
basicAuth: _basicAuth,
httpClient: _httpClient,
customAuth: _customAuth,
onCredentialsRefreshed: _onCredentialsRefreshed);
}

Expand Down
9 changes: 8 additions & 1 deletion pkgs/oauth2/lib/src/client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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;

Expand All @@ -90,11 +94,13 @@ class Client extends http.BaseClient {
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.');
}
}
Expand Down Expand Up @@ -164,6 +170,7 @@ class Client extends http.BaseClient {
secret: secret,
newScopes: newScopes,
basicAuth: _basicAuth,
customAuth: _customAuth,
httpClient: _httpClient,
);
_credentials = await _refreshingFuture!;
Expand Down
10 changes: 10 additions & 0 deletions pkgs/oauth2/lib/src/client_authenticator.dart
Original file line number Diff line number Diff line change
@@ -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<void> Function(
Map<String, String> headers, Map<String, String> body);
41 changes: 39 additions & 2 deletions pkgs/oauth2/lib/src/client_credentials_grant.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -45,6 +46,8 @@ Future<Client> clientCredentialsGrant(
bool basicAuth = true,
http.Client? httpClient,
String? delimiter,
ClientAuthenticator? customAuth,
Iterable<Uri>? resources,
Map<String, dynamic> Function(MediaType? contentType, String body)?
getParameters}) async {
Comment on lines 50 to 59

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The new resources parameter in clientCredentialsGrant is not documented. The repository style guide requires all public members to be documented. Please add documentation for this parameter to explain its purpose, which is for RFC 8707 Resource Indicators.

References
  1. At least all public members should have documentation, answering the why. (link)

delimiter ??= ' ';
Expand All @@ -54,7 +57,10 @@ Future<Client> clientCredentialsGrant(

var headers = <String, String>{};

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 {
Expand All @@ -67,6 +73,34 @@ Future<Client> clientCredentialsGrant(
body['scope'] = scopes.join(delimiter);
}

if (resources != null && resources.isNotEmpty) {
// http.post doesn't support Map<String, Iterable> 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);
}
Comment on lines +83 to +109

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This if block for handling resources duplicates a lot of logic from the code that follows it (lines 111-123). Both branches make an HTTP POST request, handle the access token response, and create a Client. This duplication can lead to maintenance issues.

Please consider refactoring to remove the duplication. You could, for instance, determine the request body and content-type first, and then have a single path for making the request and processing the response.


httpClient ??= http.Client();
var response = await httpClient.post(authorizationEndpoint,
headers: headers, body: body);
Expand All @@ -75,5 +109,8 @@ Future<Client> clientCredentialsGrant(
startTime, scopes?.toList() ?? [], delimiter,
getParameters: getParameters);
return Client(credentials,
identifier: identifier, secret: secret, httpClient: httpClient);
identifier: identifier,
secret: secret,
httpClient: httpClient,
customAuth: customAuth);
}
37 changes: 34 additions & 3 deletions pkgs/oauth2/lib/src/credentials.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -216,6 +217,8 @@ class Credentials {
String? secret,
Iterable<String>? newScopes,
bool basicAuth = true,
ClientAuthenticator? customAuth,
Iterable<Uri>? resources,
http.Client? httpClient}) async {
var scopes = this.scopes;
if (newScopes != null) scopes = newScopes.toList();
Expand All @@ -238,16 +241,44 @@ class Credentials {

var headers = <String, String>{};

var body = {'grant_type': 'refresh_token', 'refresh_token': refreshToken};
var body = <String, String>{
'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);
}
Comment on lines +266 to +287

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

There is significant code duplication between this block that handles requests with resources and the following code that handles requests without them. Both branches perform an HTTP POST, handle the response, and construct a Credentials object. This duplication makes the code harder to maintain.

Consider refactoring to unify the logic. For example, you could prepare the request body (either as a Map<String, String> or an encoded String) and then have a single httpClient.post call and a single block for response handling.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But is this code really that complicated.. we're implementing a spec, this won't be touched much. And abstractions might just make everything weirder..

Straight lines of code is awesome.. too many fancy private methods trying to share trivial logic doesn't make the world better.


var response =
await httpClient.post(tokenEndpoint, headers: headers, body: body);
var credentials = handleAccessTokenResponse(
Expand Down
Loading
Loading