A pure, platform-agnostic Dart implementation of the OAuth 2.1 Authorization
Framework (draft-ietf-oauth-v2-1-17, replacing and obsoleting RFC 6749 and
RFC 6750 while consolidating RFC 7636, RFC 8252, RFC 9700, and RFC 10017).
Build spec-conformant OAuth 2.1 Clients, Authorization Servers, and
Resource Servers with mandatory S256 PKCE, refresh token rotation, issuer
mix-up defense (iss), and strict ABNF/URI validation—without dart:io or
Flutter runtime locks.
| Entrypoint | Purpose |
|---|---|
package:oauth21/client.dart |
Client flows, PKCE (CodeVerifier, CodeChallenge), OAuthClient, and OAuthAuthenticatedClient. |
package:oauth21/server.dart |
Authorization Server (AuthorizeRequestValidator, TokenRequestHandler), Resource Server (BearerTokenValidator), and package:shelf adapters. |
package:oauth21/oauth21.dart |
Umbrella library exporting both client and server APIs. |
- Protocol Overview
- Features & Specification Coverage
- Out of Scope
- Getting Started
- Usage
- Production Deployment Requirements
- Where to Go Next
sequenceDiagram
autonumber
participant C as Client (OAuthClient)
participant AS as Auth Server (Authorize / Token)
participant RS as Resource Server (BearerTokenValidator)
C->>C: CodeVerifier.generate() + CodeChallenge.s256()
C->>AS: GET /authorize (response_type=code, code_challenge, state)
AS->>AS: AuthorizeRequestValidator.validate()
AS-->>C: 303 See Other (code, state, iss)
C->>C: parseRedirect() + validateCallback(expectedState)
C->>AS: POST /token (grant_type=authorization_code, code, code_verifier)
AS->>AS: TokenRequestHandler.handle() (PKCE S256 + single-use check)
AS-->>C: 200 OK (access_token, refresh_token, Cache-Control: no-store)
C->>RS: GET /resource (Authorization: Bearer <token>)
RS->>RS: BearerTokenValidator.validate(requiredScopes)
RS-->>C: 200 OK (Protected Resource)
| Capability | OAuth 2.1 Section | Key APIs |
|---|---|---|
Authorization Code Grant + PKCE (S256) |
Sections 4.1 & 7.5 | CodeVerifier, CodeChallenge.s256, AuthorizeRequestValidator, TokenRequestHandler |
| Refresh Token Grant & Rotation | Sections 4.3.1–4.3.3 | OAuthClient.exchangeRefreshToken, TokenRequestHandler, TokenRepository.rotateRefreshToken |
| Client Credentials Grant | Section 4.2 | OAuthClient.exchangeClientCredentials, TokenRequestHandler |
Client Authentication (client_secret_post & client_secret_basic) |
Sections 2.4.1, 3.2.1 & 3.2.4 | ClientAuthMethod.clientSecretPost (default), ClientAuthMethod.clientSecretBasic |
| Bearer Token Resource Protection | Sections 5.1–5.3 & 7.1 | OAuthAuthenticatedClient, BearerTokenValidator, bearerAuthenticationMiddleware |
| URI Category | Allowed Schemes & Rules |
|---|---|
| Authorization & Token Endpoints | https required; cleartext http permitted only on loopback (127.0.0.1, [::1], localhost) (Sections 1.5, 3.1 & 3.2). No fragments or userinfo. |
| Client Redirect URIs | https, loopback http (with ephemeral port matching 1..65535 per Sections 2.3.1, 4.1.1 & 8.4.2), or reverse-domain private-use native schemes (e.g. com.example.app:/cb, Sections 2.3.1 & 8.4.3). Relative segments (. / ..), fragments, userinfo, and dangerous schemes (javascript:, data:, file:) are rejected (Section 7.13). |
Issuer Identifier (iss) |
https (or loopback http in local development) with non-empty host and no query or fragment components (Sections 4.1.2 & 7.15.1). |
- PKCE
S256Only:plaincode challenges are rejected across client and server APIs (Sections 4.1.1 & 7.5.2);S256verification uses constant-time comparison (CryptoService.safeEquals). - Authorization Code Replay & DoS Protection: Single-use atomic consumption
(
AuthCodeRepository.tryConsume) with PKCE verification prior to family revocation (Sections 4.1.2, 4.1.3 & 7.5.3). - Refresh Token Rotation & Grace Window: Rotates refresh tokens on every redemption while preserving the original grant scope and expiration ceiling (Sections 4.3.1 & 4.3.3), with a configurable concurrency grace window (default 30 seconds) and automatic family revocation on replay.
- Query Token & Redirect Leakage Prevention:
OAuthAuthenticatedClientdisables HTTP redirect following (followRedirects = false) and rejects requests if the access token appears anywhere in the request query string (Sections 5.1 & 7.1.3.7). - Parameter Pollution & ABNF Filtering: Rejects duplicate parameters in
query strings or form bodies (Sections 3.1 & 3.2) and validates
client_id,state,code,scope,grant_type,error,error_description,error_uri, andtoken_typeagainst OAuth 2.1 Section 5.1.1 (token68) and Appendix A ABNF rules (VSCHAR,NQCHAR,NQSCHAR,name-char).
To keep the baseline audit surface small and dependency-free, the following are intentionally excluded from this package:
- Deprecated OAuth 2.0 Grants: The Implicit grant (
response_type=token), Resource Owner Password Credentials grant (grant_type=password), and PKCEplaintransformation are removed by OAuth 2.1 (Sections 7.5.2 & 10) and are not supported. - Bearer Tokens in Query Strings or Form Bodies: Query-string bearer tokens
are prohibited by OAuth 2.1 (Sections 5.1 & 7.1.3.7), and optional
form-encoded body tokens (Section 5.1.2) are not supported; only the mandatory
Authorization: Bearer <token>HTTP header (Section 5.1.1) is supported. - Optional OAuth 2.x Extension Specifications: OpenID Connect (OIDC) ID Token / JWT verification, DPoP (RFC 9449), Pushed Authorization Requests (RFC 9126), Dynamic Client Registration (RFC 7591), Token Introspection (RFC 7662), and Token Revocation (RFC 7009) are not bundled in the core package.
- Database Persistence & Login UI: Server components depend on repository
ports (
ClientRepository,AuthCodeRepository,TokenRepository,CryptoService) and delegate interactive login/consent HTML rendering to your application.
Add oauth21 to your pubspec.yaml:
dart pub add oauth21Import the entrypoint that matches your application role:
// For OAuth 2.1 client applications:
import 'package:oauth21/client.dart';
// For OAuth 2.1 authorization and resource servers:
import 'package:oauth21/server.dart';Use OAuthClient to coordinate PKCE authorization requests, callback
validation, token exchanges, and authenticated resource requests:
import 'package:http/http.dart' as http;
import 'package:oauth21/client.dart';
Future<void> runClientFlow() async {
final httpClient = http.Client();
final oauthClient = OAuthClient(
authorizationEndpoint: 'https://auth.example.com/authorize',
tokenEndpoint: 'https://auth.example.com/token',
clientId: 'my-client-id',
// Optional confidential client secret. Defaults to
// ClientAuthMethod.clientSecretPost; pass
// ClientAuthMethod.clientSecretBasic to use the Authorization header.
clientSecret: 'my-client-secret',
authMethod: ClientAuthMethod.clientSecretPost,
expectedIssuer: 'https://auth.example.com',
httpClient: HttpAdapter(httpClient),
);
// 1. Generate PKCE verifier and S256 challenge.
final codeVerifier = CodeVerifier.generate();
final codeChallenge = CodeChallenge.s256(codeVerifier);
final redirectUri = Uri.parse('https://client.example.com/callback');
const csrfState = 'random-csrf-token-123';
// 2. Build the authorization redirect URI.
final authRequest = oauthClient.createAuthorizationRequest(
redirectUri: redirectUri,
codeChallenge: codeChallenge,
state: csrfState,
scope: 'read write',
);
final authorizeUri = authRequest.toUri();
print('Redirect user agent to: $authorizeUri');
// 3. Parse and validate the callback URI from the authorization server.
final callbackUri = Uri.parse(
'https://client.example.com/callback'
'?code=issued-auth-code'
'&state=$csrfState'
'&iss=https%3A%2F%2Fauth.example.com',
);
final callbackParams = oauthClient.requestParser.parseRedirect(callbackUri);
oauthClient.validateCallback(callbackParams, expectedState: csrfState);
// 4. Exchange the authorization code for tokens.
final tokenResult = await oauthClient.exchangeCode(
code: callbackParams.code!,
codeVerifier: codeVerifier,
redirectUri: redirectUri,
);
switch (tokenResult) {
case TokenSuccess(
:final accessToken,
:final refreshToken,
:final tokenType,
):
// 5. Call a protected resource server using OAuthAuthenticatedClient.
final apiClient = OAuthAuthenticatedClient(
httpClient,
accessToken,
tokenType: tokenType,
);
final apiResponse = await apiClient.get(
Uri.parse('https://api.example.com/v1/profile'),
);
print('Profile status: ${apiResponse.statusCode}');
// 6. Refresh an access token (optionally narrowing scope).
if (refreshToken != null) {
final refreshed = await oauthClient.exchangeRefreshToken(
refreshToken,
scope: 'read',
);
print('Refresh result: $refreshed');
}
case TokenError(:final error, :final errorDescription):
print('Token request failed: $error ($errorDescription)');
}
// Confidential clients can also request machine-to-machine tokens:
final ccResult = await oauthClient.exchangeClientCredentials(scope: 'read');
print('Client credentials result: $ccResult');
}Implement the four server ports (ClientRepository, AuthCodeRepository,
TokenRepository, and CryptoService), then wire AuthorizeRequestValidator
and TokenRequestHandler into your HTTP framework:
import 'package:oauth21/server.dart';
Future<void> handleServerRequests({
required ClientRepository clientRepository,
required AuthCodeRepository authCodeRepository,
required TokenRepository tokenRepository,
required CryptoService cryptoService,
required Map<String, List<String>> authorizeQueryParams,
required Map<String, String> tokenBodyParams,
String? authorizationHeader,
}) async {
// 1. Validate GET /authorize query parameters.
final authorizeValidator = AuthorizeRequestValidator(clientRepository);
final authResult = await authorizeValidator.validate(authorizeQueryParams);
switch (authResult) {
case AuthorizeRequestValid(
:final client,
:final validatedRedirectUri,
:final requestedScopes,
):
// Render login/consent UI, then issue an AuthCode and 303 redirect.
print(
'Consent needed for ${client.clientId} '
'at $validatedRedirectUri ($requestedScopes)',
);
case AuthorizeRedirectableError(
:final validatedRedirectUri,
:final errorCode,
:final errorDescription,
:final state,
):
// Redirect 303 back to validatedRedirectUri with error, state, and iss.
final errorLocation = validatedRedirectUri.replace(
queryParameters: {
...validatedRedirectUri.queryParameters,
'error': errorCode,
'error_description': errorDescription,
if (state != null) 'state': state,
'iss': 'https://auth.example.com',
},
);
print('Redirect 303 to: $errorLocation');
case AuthorizeDirectError(:final errorCode, :final errorDescription):
// Do NOT redirect; render a 400 Bad Request page directly.
print('Direct 400 error: $errorCode ($errorDescription)');
}
// 2. Process POST /token requests.
final tokenHandler = TokenRequestHandler(
clientRepository: clientRepository,
authCodeRepository: authCodeRepository,
tokenRepository: tokenRepository,
cryptoService: cryptoService,
);
final tokenResponse = await tokenHandler.handle(
tokenBodyParams,
authorizationHeader: authorizationHeader,
isHttps: true,
);
print('Token status: ${tokenResponse.statusCode}');
}Use BearerTokenValidator directly in any Dart server framework to validate
Authorization: Bearer <token> headers and required scopes:
import 'package:oauth21/server.dart';
Future<void> verifyProtectedResource({
required TokenRepository tokenRepository,
required CryptoService cryptoService,
required List<String> authorizationHeaders,
}) async {
final validator = BearerTokenValidator(
tokenRepository: tokenRepository,
cryptoService: cryptoService,
realm: 'example-api',
errorUri: 'https://api.example.com/docs/errors',
);
final result = await validator.validate(
authorizationHeaders: authorizationHeaders,
requiredScopes: const {'read'},
isHttps: true,
);
switch (result) {
case BearerTokenValid(:final accessToken):
print('Authorized subject: ${accessToken.userId}');
case BearerTokenInvalid(:final statusCode, :final headers):
// Return statusCode (400, 401, or 403) and headers['WWW-Authenticate'].
print('Rejected ($statusCode): ${headers['WWW-Authenticate']}');
}
}When using package:shelf, plug handleAuthorizeRequest, handleTokenRequest,
and bearerAuthenticationMiddleware directly into your routes:
import 'package:oauth21/server.dart';
import 'package:shelf/shelf.dart' as shelf;
shelf.Handler buildShelfRoutes({
required AuthorizeRequestValidator authorizeValidator,
required TokenRequestHandler tokenHandler,
required BearerTokenValidator bearerValidator,
required AuthCodeRepository authCodeRepo,
required CryptoService cryptoService,
}) {
const issuer = 'https://auth.example.com';
final protectedResource = const shelf.Pipeline()
.addMiddleware(
bearerAuthenticationMiddleware(
validator: bearerValidator,
requiredScopes: const {'read'},
isHttps: true,
),
)
.addHandler((request) {
final token = getAccessTokenFromContext(request)!;
return shelf.Response.ok('User: ${token.userId}');
});
return (shelf.Request request) async {
return switch (request.url.path) {
'authorize' => handleAuthorizeRequest(
request,
validator: authorizeValidator,
issuer: issuer,
onConsentRequired: (req, valid) async {
final code = cryptoService.generateRandomEntropyString();
await authCodeRepo.save(
AuthCode(
code: code,
clientId: valid.client.clientId,
redirectUri: valid.validatedRedirectUri,
userId: 'user-123',
codeChallenge: valid.codeChallenge,
codeChallengeMethod: valid.codeChallengeMethod,
expiresAt: DateTime.now().add(const Duration(minutes: 5)),
scope: valid.requestedScopes.join(' '),
),
);
return redirectWithAuthorizationCode(
redirectUri: valid.validatedRedirectUri,
code: code,
state: valid.state,
issuer: issuer,
);
},
),
'token' => handleTokenRequest(
request,
handler: tokenHandler,
isHttps: true,
),
'resource' => protectedResource(request),
_ => shelf.Response.notFound('Not Found'),
};
};
}Protocol-level invariants are enforced automatically, but production deployments must configure the surrounding host environment to satisfy these OAuth 2.1 requirements:
- TLS Termination &
isHttps(Sections 1.5, 7.6 & 7.14): Serve authorization, token, and resource endpoints over TLS. PassisHttps: trueonly whenrequest.requestedUri.scheme == 'https'or when a trusted reverse proxy has verified TLS termination. - Clickjacking Headers on Consent UI (Section 7.11): Direct error responses
from
handleAuthorizeRequestincludeX-Frame-Options: DENYandContent-Security-Policy: frame-ancestors 'none'. Interactive HTML login and consent responses returned byonConsentRequiredmust also set these headers. - Rate Limiting (Sections 2.4.1 & 7.7): Enforce brute-force rate limiting
and account lockout controls on
/tokenand login endpoints at the reverse proxy or middleware layer. - Cryptographic Entropy (Section 7.7): Implement
CryptoService.generateRandomEntropyStringwithRandom.secure()providing at least 128 bits (and preferably >= 160 bits; 256 bits by default) of entropy. - Atomic Persistence (Sections 4.1.2, 4.1.3 & 4.3.3): Implement
AuthCodeRepository.tryConsume,TokenRepository.saveIssuedTokens, andTokenRepository.rotateRefreshTokenwith atomic database transactions or compare-and-set operations so concurrent replay attempts are reliably detected and trigger token family revocation.
- Runnable End-to-End Demo: See
example/oauth21_example.dartfor a complete, self-contained script running an in-memory Shelf Authorization Server andOAuthClientflow (dart run example/oauth21_example.dart). - Roadmap: Planned follow-up work includes optional OAuth 2.x security extensions such as Demonstrating Proof-of-Possession (DPoP, RFC 9449).
- Issues & Feedback: File bugs or feature requests on the GitHub issue tracker.
- Contributing: Run
dart format .,dart analyze --fatal-infos, anddart testbefore opening a pull request.