dpop_flutter
An HTTP-client-agnostic implementation of OAuth 2.0 DPoP (RFC 9449 - Demonstrating Proof of Possession) for Dart and Flutter.
This package only produces Authorization/DPoP headers - it never sends a
request itself. Use it with package:http via
dpop_flutter_http, with Dio
via dpop_flutter_dio, or
attach the headers to any other HTTP client manually.
What problem does DPoP solve?
A normal OAuth bearer token is just a string:
Flutter --Access Token--> API
Anyone who obtains that string - via a leaked log, a misconfigured proxy, a compromised dependency - can replay it as if they were the legitimate client:
Attacker --stolen access token--> API
DPoP fixes this by binding the token to a private key that never leaves the device. Every request additionally carries a short-lived, signed proof that only the holder of that key could have produced:
Flutter
├── Access Token
└── Private Key --signs--> DPoP Proof
│
Access Token + DPoP Proof ────────┴──> API
An attacker who steals only the access token (not the private key) cannot produce a valid proof, so a DPoP-enforcing server rejects the replayed request:
Stolen Access Token + No Private Key = Rejected
Installation
dependencies:
dpop_flutter: ^0.1.0
Quick start
import 'package:dpop_flutter/dpop_flutter.dart';
final dpop = DpopClient(keyStore: SecureDpopKeyStore());
await dpop.initialize();
final headers = await dpop.createHeaders(
method: 'GET',
uri: Uri.parse('https://api.example.com/profile'),
accessToken: accessToken,
);
// headers == {'Authorization': 'DPoP <accessToken>', 'DPoP': '<proof-jwt>'}
Send those headers with whichever HTTP client you already use:
package:http
final response = await http.get(uri, headers: await dpop.createHeaders(
method: 'GET',
uri: uri,
accessToken: accessToken,
));
Or use dpop_flutter_http's
DpopHttpClient for a drop-in http.Client that attaches headers (and
handles nonce retries) automatically.
Dio
Use dpop_flutter_dio's
DpopInterceptor:
dio.interceptors.add(DpopInterceptor(dpop: dpop, dio: dio, tokenProvider: tokenProvider));
Custom HTTP client
Any client that lets you set headers can use this package - it doesn't care:
final headers = await dpop.createHeaders(method: request.method, uri: request.uri, accessToken: token);
request.headers.addAll(headers);
Token refresh
dpop_flutter never manages login, refresh, or session state. You pass the
current access token into createHeaders (directly, or via an
AccessTokenProvider when using an adapter) every time - if your app
refreshes the token, the next createHeaders call simply receives the new
value. There is nothing to invalidate or resynchronize on the DPoP side.
Nonce
Some servers require a per-origin nonce, delivered via a DPoP-Nonce
response header (RFC 9449 §8). DpopClient tracks this automatically
through its DpopNonceStore (in-memory and origin-scoped by default): call
await dpop.handleNonceChallenge(uri, response.headers['dpop-nonce']!);
after a 401 that carries a DPoP-Nonce header, and the next
createHeaders call for that origin will include it. Both
dpop_flutter_http and dpop_flutter_dio do this automatically, including
retrying the failed request exactly once with a fresh proof.
Key rotation
await dpop.rotateKey();
Rotation is never automatic. Rotating replaces the key pair immediately,
which means any access token bound to the old key (via cnf.jkt on the
server) will no longer produce valid proofs. Only rotate when your
application is prepared to re-authenticate or re-bind - for example, right
after a full re-login, not as a background maintenance task.
Multiple accounts
Give each signed-in account its own key by scoping SecureDpopKeyStore's
storage key:
final dpopForAccountA = DpopClient(keyStore: SecureDpopKeyStore(storageKey: 'dpop_key_$accountAId'));
final dpopForAccountB = DpopClient(keyStore: SecureDpopKeyStore(storageKey: 'dpop_key_$accountBId'));
Security
See doc/security.md in this repository for the
full threat model, cryptography and dependency rationale, and what is
explicitly out of scope (DPoP does not replace HTTPS, OAuth, or backend
authorization).
Failure handling
Every error this package raises is a DpopException subtype
(DpopKeyException, DpopProofException, DpopNonceException,
DpopConfigurationException, DpopStorageException, DpopCryptoException,
DpopValidationException) with a log-safe message - never a key, token, or
full proof. Network-level failures (timeouts, DNS, 5xx) are left to your
HTTP client's own error types; this package does not wrap them.
ASP.NET Core
See doc/aspnetcore_interop.md for what a
resource server needs to check to validate proofs produced by this package,
including an illustrative ASP.NET Core middleware sketch.
FAQ
Does DPoP replace HTTPS? No - always use HTTPS. DPoP protects against token replay, not network eavesdropping.
Does DPoP replace OAuth? No - it's an enhancement to OAuth 2.0 access
tokens (and, via dpop_jkt, authorization codes), not a replacement for the
authorization flow itself.
What happens if the access token is stolen? Without the matching private key, a thief cannot produce a valid proof, so a DPoP-enforcing server rejects the replayed token.
What happens if the private key is stolen? Then the attacker can
produce valid proofs. Platform secure storage (Keychain/Keystore/DPAPI/
libsecret) makes extracting the key significantly harder, but DPoP does not
protect against a fully compromised device - see doc/security.md.
What happens after app reinstall? A new key pair is generated; the app looks, to the authorization server, like a new device. This is expected.
Does DPoP work with Dio? Yes, via dpop_flutter_dio - it is not
required by the core.
Does DPoP work with package:http? Yes, via dpop_flutter_http - also
not required by the core.
Can I use another HTTP client? Yes - createHeaders() returns a plain
Map<String, String> that works with any client.
Does the backend need Redis? No requirement from this package - your
resource server needs some short-lived store for recently-seen jti
values to detect replay, and for nonces if you use them; an in-memory cache
is enough for a single-instance API, a distributed cache for multi-instance
deployments.
Does DPoP protect against a compromised device? No - see the "What is
out of scope" section of doc/security.md.
Contributing
Issues and PRs are welcome - see
CONTRIBUTING.md
in the repo for setup, workflow, and what a PR should include, and
CODE_OF_CONDUCT.md.
Found a security issue? Please don't open a public issue - see
SECURITY.md instead.
Libraries
- dpop_flutter
- HTTP-client-agnostic OAuth 2.0 DPoP (RFC 9449) for Dart and Flutter.
- testing
- Test doubles for
dpop_flutterand its adapters.