Compare changes

Choose any two refs to compare.

Changed files
+7365 -13981
.vscode
android
app
src
main
res
drawable-hdpi
drawable-mdpi
drawable-xhdpi
drawable-xxhdpi
drawable-xxxhdpi
mipmap-hdpi
mipmap-mdpi
mipmap-xhdpi
mipmap-xxhdpi
mipmap-xxxhdpi
assets
ios
lib
packages
test
+59
.vscode/launch.json
···
+
{
+
"version": "0.2.0",
+
"configurations": [
+
{
+
"name": "Dev (Local Server)",
+
"request": "launch",
+
"type": "dart",
+
"flutterMode": "debug",
+
"args": [
+
"--flavor",
+
"dev",
+
"--dart-define=FLUTTER_FLAVOR=dev"
+
]
+
},
+
{
+
"name": "Dev (Local Server) - Release",
+
"request": "launch",
+
"type": "dart",
+
"flutterMode": "release",
+
"args": [
+
"--flavor",
+
"dev",
+
"--dart-define=FLUTTER_FLAVOR=dev"
+
]
+
},
+
{
+
"name": "Prod (Production Server)",
+
"request": "launch",
+
"type": "dart",
+
"flutterMode": "debug",
+
"args": [
+
"--flavor",
+
"prod",
+
"--dart-define=FLUTTER_FLAVOR=prod"
+
]
+
},
+
{
+
"name": "Prod (Production Server) - Release",
+
"request": "launch",
+
"type": "dart",
+
"flutterMode": "release",
+
"args": [
+
"--flavor",
+
"prod",
+
"--dart-define=FLUTTER_FLAVOR=prod"
+
]
+
},
+
{
+
"name": "Legacy: Local (no flavor)",
+
"request": "launch",
+
"type": "dart",
+
"flutterMode": "debug",
+
"args": [
+
"--dart-define=ENVIRONMENT=local"
+
]
+
}
+
],
+
"compounds": []
+
}
+16 -1
android/app/build.gradle.kts
···
}
defaultConfig {
-
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
+
// Base application ID - flavors will add suffixes
applicationId = "social.coves"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
···
versionName = flutter.versionName
}
+
// Flutter flavors for side-by-side installation
+
flavorDimensions += "environment"
+
productFlavors {
+
create("prod") {
+
dimension = "environment"
+
applicationIdSuffix = ""
+
resValue("string", "app_name", "Coves")
+
}
+
create("dev") {
+
dimension = "environment"
+
applicationIdSuffix = ".dev"
+
resValue("string", "app_name", "Coves Dev")
+
}
+
}
+
buildTypes {
release {
// TODO: Add your own signing config for the release build.
android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png

This is a binary file and will not be displayed.

android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png

This is a binary file and will not be displayed.

android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png

This is a binary file and will not be displayed.

android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png

This is a binary file and will not be displayed.

android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png

This is a binary file and will not be displayed.

android/app/src/main/res/mipmap-hdpi/ic_launcher.png

This is a binary file and will not be displayed.

android/app/src/main/res/mipmap-mdpi/ic_launcher.png

This is a binary file and will not be displayed.

android/app/src/main/res/mipmap-xhdpi/ic_launcher.png

This is a binary file and will not be displayed.

android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png

This is a binary file and will not be displayed.

android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png

This is a binary file and will not be displayed.

assets/logo/lil_dude.png

This is a binary file and will not be displayed.

assets/logo/lil_dude_padded.png

This is a binary file and will not be displayed.

+8
ios/Flutter/Dev-Debug.xcconfig
···
+
#include "Generated.xcconfig"
+
#include "Debug.xcconfig"
+
+
// Dev flavor configuration
+
PRODUCT_BUNDLE_IDENTIFIER=social.coves.dev
+
PRODUCT_NAME=Coves Dev
+
DISPLAY_NAME=Coves Dev
+
FLUTTER_FLAVOR=dev
+8
ios/Flutter/Dev-Release.xcconfig
···
+
#include "Generated.xcconfig"
+
#include "Release.xcconfig"
+
+
// Dev flavor configuration
+
PRODUCT_BUNDLE_IDENTIFIER=social.coves.dev
+
PRODUCT_NAME=Coves Dev
+
DISPLAY_NAME=Coves Dev
+
FLUTTER_FLAVOR=dev
+8
ios/Flutter/Prod-Debug.xcconfig
···
+
#include "Generated.xcconfig"
+
#include "Debug.xcconfig"
+
+
// Prod flavor configuration
+
PRODUCT_BUNDLE_IDENTIFIER=social.coves
+
PRODUCT_NAME=Coves
+
DISPLAY_NAME=Coves
+
FLUTTER_FLAVOR=prod
+8
ios/Flutter/Prod-Release.xcconfig
···
+
#include "Generated.xcconfig"
+
#include "Release.xcconfig"
+
+
// Prod flavor configuration
+
PRODUCT_BUNDLE_IDENTIFIER=social.coves
+
PRODUCT_NAME=Coves
+
DISPLAY_NAME=Coves
+
FLUTTER_FLAVOR=prod
ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png

This is a binary file and will not be displayed.

ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png

This is a binary file and will not be displayed.

ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png

This is a binary file and will not be displayed.

ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png

This is a binary file and will not be displayed.

ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png

This is a binary file and will not be displayed.

ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png

This is a binary file and will not be displayed.

ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png

This is a binary file and will not be displayed.

ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png

This is a binary file and will not be displayed.

ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png

This is a binary file and will not be displayed.

ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png

This is a binary file and will not be displayed.

ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png

This is a binary file and will not be displayed.

ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png

This is a binary file and will not be displayed.

ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png

This is a binary file and will not be displayed.

ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png

This is a binary file and will not be displayed.

ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png

This is a binary file and will not be displayed.

ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png

This is a binary file and will not be displayed.

ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png

This is a binary file and will not be displayed.

ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png

This is a binary file and will not be displayed.

ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png

This is a binary file and will not be displayed.

ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png

This is a binary file and will not be displayed.

ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png

This is a binary file and will not be displayed.

+47 -14
lib/config/environment_config.dart
···
/// Environment Configuration for Coves Mobile
///
/// Supports multiple environments:
-
/// - Production: Real Bluesky infrastructure
-
/// - Local: Local PDS + PLC for development/testing
+
/// - Production: Real Bluesky infrastructure (prod flavor)
+
/// - Local: Local PDS + PLC for development/testing (dev flavor)
///
-
/// Set via ENVIRONMENT environment variable or flutter run --dart-define
+
/// Environment is determined by (in priority order):
+
/// 1. --dart-define=ENVIRONMENT=local/production (explicit override)
+
/// 2. Flutter flavor (dev -> local, prod -> production)
+
/// 3. Default: production
enum Environment { production, local }
class EnvironmentConfig {
···
final String plcDirectoryUrl;
/// Production configuration (default)
-
/// Uses real Bluesky infrastructure
+
/// Uses Coves production server with public atproto infrastructure
static const production = EnvironmentConfig(
environment: Environment.production,
-
apiUrl: 'https://coves.social', // TODO: Update when production is live
+
apiUrl: 'https://coves.social',
handleResolverUrl:
'https://bsky.social/xrpc/com.atproto.identity.resolveHandle',
plcDirectoryUrl: 'https://plc.directory',
···
plcDirectoryUrl: 'http://localhost:3002',
);
+
/// Flutter flavor passed via --flavor flag
+
/// This is set automatically by Flutter build system
+
static const String _flavor = String.fromEnvironment('FLUTTER_FLAVOR');
+
+
/// Explicit environment override via --dart-define=ENVIRONMENT=local
+
static const String _envOverride = String.fromEnvironment('ENVIRONMENT');
+
/// Get current environment based on build configuration
+
///
+
/// Priority:
+
/// 1. Explicit --dart-define=ENVIRONMENT=local/production
+
/// 2. Flavor: dev -> local, prod -> production
+
/// 3. Default: production
static EnvironmentConfig get current {
-
// Read from --dart-define=ENVIRONMENT=local
-
const envString = String.fromEnvironment(
-
'ENVIRONMENT',
-
defaultValue: 'production',
-
);
+
// Priority 1: Explicit environment override
+
if (_envOverride.isNotEmpty) {
+
switch (_envOverride) {
+
case 'local':
+
return local;
+
case 'production':
+
return production;
+
}
+
}
-
switch (envString) {
-
case 'local':
+
// Priority 2: Flavor-based environment
+
switch (_flavor) {
+
case 'dev':
return local;
-
case 'production':
-
default:
+
case 'prod':
return production;
}
+
+
// Default: production
+
return production;
+
}
+
+
/// Get the current flavor name for display purposes
+
static String get flavorName {
+
if (_flavor.isNotEmpty) {
+
return _flavor;
+
}
+
if (_envOverride == 'local') {
+
return 'dev';
+
}
+
return 'prod';
}
bool get isProduction => environment == Environment.production;
+112
lib/models/coves_session.dart
···
+
import 'dart:convert';
+
+
/// Coves Session Model
+
///
+
/// Simplified session model for the backend OAuth flow.
+
/// The backend handles all the complexity (DPoP, PKCE, token refresh)
+
/// and gives us a sealed token that's opaque to the client.
+
///
+
/// This replaces the complex TokenSet + DPoP keys from atproto_oauth_flutter.
+
class CovesSession {
+
const CovesSession({
+
required this.token,
+
required this.did,
+
required this.sessionId,
+
this.handle,
+
});
+
+
/// Create a session from OAuth callback parameters
+
///
+
/// Expected URL format (RFC 8252 private-use URI scheme):
+
/// `social.coves:/callback?token=...&did=...&session_id=...&handle=...`
+
factory CovesSession.fromCallbackUri(Uri uri) {
+
final token = uri.queryParameters['token'];
+
final did = uri.queryParameters['did'];
+
final sessionId = uri.queryParameters['session_id'];
+
final handle = uri.queryParameters['handle'];
+
+
if (token == null || token.isEmpty) {
+
throw const FormatException('Missing required parameter: token');
+
}
+
if (did == null || did.isEmpty) {
+
throw const FormatException('Missing required parameter: did');
+
}
+
if (sessionId == null || sessionId.isEmpty) {
+
throw const FormatException('Missing required parameter: session_id');
+
}
+
+
return CovesSession(
+
token: Uri.decodeComponent(token),
+
did: did,
+
sessionId: sessionId,
+
handle: handle,
+
);
+
}
+
+
/// Create a session from JSON (for storage restoration)
+
factory CovesSession.fromJson(Map<String, dynamic> json) {
+
return CovesSession(
+
token: json['token'] as String,
+
did: json['did'] as String,
+
sessionId: json['session_id'] as String,
+
handle: json['handle'] as String?,
+
);
+
}
+
+
/// Create a session from a JSON string
+
factory CovesSession.fromJsonString(String jsonString) {
+
return CovesSession.fromJson(
+
jsonDecode(jsonString) as Map<String, dynamic>,
+
);
+
}
+
+
/// The sealed session token (AES-256-GCM encrypted by backend)
+
///
+
/// This token is opaque to the client - we just store and send it.
+
/// Use in Authorization header: `Authorization: Bearer $token`
+
final String token;
+
+
/// User's DID (decentralized identifier)
+
///
+
/// Example: did:plc:abc123
+
final String did;
+
+
/// Session ID for refresh operations
+
///
+
/// The backend uses this to identify the session for token refresh.
+
final String sessionId;
+
+
/// User's handle (optional)
+
///
+
/// Example: alice.bsky.social
+
/// May be null if the backend didn't include it in the callback.
+
final String? handle;
+
+
/// Convert to JSON for storage
+
Map<String, dynamic> toJson() {
+
return {
+
'token': token,
+
'did': did,
+
'session_id': sessionId,
+
if (handle != null) 'handle': handle,
+
};
+
}
+
+
/// Convert to JSON string for storage
+
String toJsonString() => jsonEncode(toJson());
+
+
/// Create a copy with updated token (for refresh)
+
CovesSession copyWithToken(String newToken) {
+
return CovesSession(
+
token: newToken,
+
did: did,
+
sessionId: sessionId,
+
handle: handle,
+
);
+
}
+
+
@override
+
String toString() {
+
return 'CovesSession(did: $did, handle: $handle, sessionId: $sessionId)';
+
}
+
}
+1102
test/services/coves_auth_service_test.mocks.dart
···
+
// Mocks generated by Mockito 5.4.6 from annotations
+
// in coves_flutter/test/services/coves_auth_service_test.dart.
+
// Do not manually edit this file.
+
+
// ignore_for_file: no_leading_underscores_for_library_prefixes
+
import 'dart:async' as _i9;
+
+
import 'package:dio/src/adapter.dart' as _i4;
+
import 'package:dio/src/cancel_token.dart' as _i10;
+
import 'package:dio/src/dio.dart' as _i7;
+
import 'package:dio/src/dio_mixin.dart' as _i3;
+
import 'package:dio/src/options.dart' as _i2;
+
import 'package:dio/src/response.dart' as _i6;
+
import 'package:dio/src/transformer.dart' as _i5;
+
import 'package:flutter/foundation.dart' as _i11;
+
import 'package:flutter_secure_storage/flutter_secure_storage.dart' as _i8;
+
import 'package:mockito/mockito.dart' as _i1;
+
+
// ignore_for_file: type=lint
+
// ignore_for_file: avoid_redundant_argument_values
+
// ignore_for_file: avoid_setters_without_getters
+
// ignore_for_file: comment_references
+
// ignore_for_file: deprecated_member_use
+
// ignore_for_file: deprecated_member_use_from_same_package
+
// ignore_for_file: implementation_imports
+
// ignore_for_file: invalid_use_of_visible_for_testing_member
+
// ignore_for_file: must_be_immutable
+
// ignore_for_file: prefer_const_constructors
+
// ignore_for_file: unnecessary_parenthesis
+
// ignore_for_file: camel_case_types
+
// ignore_for_file: subtype_of_sealed_class
+
// ignore_for_file: invalid_use_of_internal_member
+
+
class _FakeBaseOptions_0 extends _i1.SmartFake implements _i2.BaseOptions {
+
_FakeBaseOptions_0(Object parent, Invocation parentInvocation)
+
: super(parent, parentInvocation);
+
}
+
+
class _FakeInterceptors_1 extends _i1.SmartFake implements _i3.Interceptors {
+
_FakeInterceptors_1(Object parent, Invocation parentInvocation)
+
: super(parent, parentInvocation);
+
}
+
+
class _FakeHttpClientAdapter_2 extends _i1.SmartFake
+
implements _i4.HttpClientAdapter {
+
_FakeHttpClientAdapter_2(Object parent, Invocation parentInvocation)
+
: super(parent, parentInvocation);
+
}
+
+
class _FakeTransformer_3 extends _i1.SmartFake implements _i5.Transformer {
+
_FakeTransformer_3(Object parent, Invocation parentInvocation)
+
: super(parent, parentInvocation);
+
}
+
+
class _FakeResponse_4<T1> extends _i1.SmartFake implements _i6.Response<T1> {
+
_FakeResponse_4(Object parent, Invocation parentInvocation)
+
: super(parent, parentInvocation);
+
}
+
+
class _FakeDio_5 extends _i1.SmartFake implements _i7.Dio {
+
_FakeDio_5(Object parent, Invocation parentInvocation)
+
: super(parent, parentInvocation);
+
}
+
+
class _FakeIOSOptions_6 extends _i1.SmartFake implements _i8.IOSOptions {
+
_FakeIOSOptions_6(Object parent, Invocation parentInvocation)
+
: super(parent, parentInvocation);
+
}
+
+
class _FakeAndroidOptions_7 extends _i1.SmartFake
+
implements _i8.AndroidOptions {
+
_FakeAndroidOptions_7(Object parent, Invocation parentInvocation)
+
: super(parent, parentInvocation);
+
}
+
+
class _FakeLinuxOptions_8 extends _i1.SmartFake implements _i8.LinuxOptions {
+
_FakeLinuxOptions_8(Object parent, Invocation parentInvocation)
+
: super(parent, parentInvocation);
+
}
+
+
class _FakeWindowsOptions_9 extends _i1.SmartFake
+
implements _i8.WindowsOptions {
+
_FakeWindowsOptions_9(Object parent, Invocation parentInvocation)
+
: super(parent, parentInvocation);
+
}
+
+
class _FakeWebOptions_10 extends _i1.SmartFake implements _i8.WebOptions {
+
_FakeWebOptions_10(Object parent, Invocation parentInvocation)
+
: super(parent, parentInvocation);
+
}
+
+
class _FakeMacOsOptions_11 extends _i1.SmartFake implements _i8.MacOsOptions {
+
_FakeMacOsOptions_11(Object parent, Invocation parentInvocation)
+
: super(parent, parentInvocation);
+
}
+
+
/// A class which mocks [Dio].
+
///
+
/// See the documentation for Mockito's code generation for more information.
+
class MockDio extends _i1.Mock implements _i7.Dio {
+
MockDio() {
+
_i1.throwOnMissingStub(this);
+
}
+
+
@override
+
_i2.BaseOptions get options =>
+
(super.noSuchMethod(
+
Invocation.getter(#options),
+
returnValue: _FakeBaseOptions_0(this, Invocation.getter(#options)),
+
)
+
as _i2.BaseOptions);
+
+
@override
+
_i3.Interceptors get interceptors =>
+
(super.noSuchMethod(
+
Invocation.getter(#interceptors),
+
returnValue: _FakeInterceptors_1(
+
this,
+
Invocation.getter(#interceptors),
+
),
+
)
+
as _i3.Interceptors);
+
+
@override
+
_i4.HttpClientAdapter get httpClientAdapter =>
+
(super.noSuchMethod(
+
Invocation.getter(#httpClientAdapter),
+
returnValue: _FakeHttpClientAdapter_2(
+
this,
+
Invocation.getter(#httpClientAdapter),
+
),
+
)
+
as _i4.HttpClientAdapter);
+
+
@override
+
_i5.Transformer get transformer =>
+
(super.noSuchMethod(
+
Invocation.getter(#transformer),
+
returnValue: _FakeTransformer_3(
+
this,
+
Invocation.getter(#transformer),
+
),
+
)
+
as _i5.Transformer);
+
+
@override
+
set options(_i2.BaseOptions? value) => super.noSuchMethod(
+
Invocation.setter(#options, value),
+
returnValueForMissingStub: null,
+
);
+
+
@override
+
set httpClientAdapter(_i4.HttpClientAdapter? value) => super.noSuchMethod(
+
Invocation.setter(#httpClientAdapter, value),
+
returnValueForMissingStub: null,
+
);
+
+
@override
+
set transformer(_i5.Transformer? value) => super.noSuchMethod(
+
Invocation.setter(#transformer, value),
+
returnValueForMissingStub: null,
+
);
+
+
@override
+
void close({bool? force = false}) => super.noSuchMethod(
+
Invocation.method(#close, [], {#force: force}),
+
returnValueForMissingStub: null,
+
);
+
+
@override
+
_i9.Future<_i6.Response<T>> head<T>(
+
String? path, {
+
Object? data,
+
Map<String, dynamic>? queryParameters,
+
_i2.Options? options,
+
_i10.CancelToken? cancelToken,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#head,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
},
+
),
+
returnValue: _i9.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#head,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
},
+
),
+
),
+
),
+
)
+
as _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i9.Future<_i6.Response<T>> headUri<T>(
+
Uri? uri, {
+
Object? data,
+
_i2.Options? options,
+
_i10.CancelToken? cancelToken,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#headUri,
+
[uri],
+
{#data: data, #options: options, #cancelToken: cancelToken},
+
),
+
returnValue: _i9.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#headUri,
+
[uri],
+
{#data: data, #options: options, #cancelToken: cancelToken},
+
),
+
),
+
),
+
)
+
as _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i9.Future<_i6.Response<T>> get<T>(
+
String? path, {
+
Object? data,
+
Map<String, dynamic>? queryParameters,
+
_i2.Options? options,
+
_i10.CancelToken? cancelToken,
+
_i2.ProgressCallback? onReceiveProgress,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#get,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
returnValue: _i9.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#get,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
),
+
),
+
)
+
as _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i9.Future<_i6.Response<T>> getUri<T>(
+
Uri? uri, {
+
Object? data,
+
_i2.Options? options,
+
_i10.CancelToken? cancelToken,
+
_i2.ProgressCallback? onReceiveProgress,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#getUri,
+
[uri],
+
{
+
#data: data,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
returnValue: _i9.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#getUri,
+
[uri],
+
{
+
#data: data,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
),
+
),
+
)
+
as _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i9.Future<_i6.Response<T>> post<T>(
+
String? path, {
+
Object? data,
+
Map<String, dynamic>? queryParameters,
+
_i2.Options? options,
+
_i10.CancelToken? cancelToken,
+
_i2.ProgressCallback? onSendProgress,
+
_i2.ProgressCallback? onReceiveProgress,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#post,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
returnValue: _i9.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#post,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
),
+
),
+
)
+
as _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i9.Future<_i6.Response<T>> postUri<T>(
+
Uri? uri, {
+
Object? data,
+
_i2.Options? options,
+
_i10.CancelToken? cancelToken,
+
_i2.ProgressCallback? onSendProgress,
+
_i2.ProgressCallback? onReceiveProgress,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#postUri,
+
[uri],
+
{
+
#data: data,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
returnValue: _i9.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#postUri,
+
[uri],
+
{
+
#data: data,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
),
+
),
+
)
+
as _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i9.Future<_i6.Response<T>> put<T>(
+
String? path, {
+
Object? data,
+
Map<String, dynamic>? queryParameters,
+
_i2.Options? options,
+
_i10.CancelToken? cancelToken,
+
_i2.ProgressCallback? onSendProgress,
+
_i2.ProgressCallback? onReceiveProgress,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#put,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
returnValue: _i9.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#put,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
),
+
),
+
)
+
as _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i9.Future<_i6.Response<T>> putUri<T>(
+
Uri? uri, {
+
Object? data,
+
_i2.Options? options,
+
_i10.CancelToken? cancelToken,
+
_i2.ProgressCallback? onSendProgress,
+
_i2.ProgressCallback? onReceiveProgress,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#putUri,
+
[uri],
+
{
+
#data: data,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
returnValue: _i9.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#putUri,
+
[uri],
+
{
+
#data: data,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
),
+
),
+
)
+
as _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i9.Future<_i6.Response<T>> patch<T>(
+
String? path, {
+
Object? data,
+
Map<String, dynamic>? queryParameters,
+
_i2.Options? options,
+
_i10.CancelToken? cancelToken,
+
_i2.ProgressCallback? onSendProgress,
+
_i2.ProgressCallback? onReceiveProgress,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#patch,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
returnValue: _i9.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#patch,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
),
+
),
+
)
+
as _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i9.Future<_i6.Response<T>> patchUri<T>(
+
Uri? uri, {
+
Object? data,
+
_i2.Options? options,
+
_i10.CancelToken? cancelToken,
+
_i2.ProgressCallback? onSendProgress,
+
_i2.ProgressCallback? onReceiveProgress,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#patchUri,
+
[uri],
+
{
+
#data: data,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
returnValue: _i9.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#patchUri,
+
[uri],
+
{
+
#data: data,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
),
+
),
+
)
+
as _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i9.Future<_i6.Response<T>> delete<T>(
+
String? path, {
+
Object? data,
+
Map<String, dynamic>? queryParameters,
+
_i2.Options? options,
+
_i10.CancelToken? cancelToken,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#delete,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
},
+
),
+
returnValue: _i9.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#delete,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
},
+
),
+
),
+
),
+
)
+
as _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i9.Future<_i6.Response<T>> deleteUri<T>(
+
Uri? uri, {
+
Object? data,
+
_i2.Options? options,
+
_i10.CancelToken? cancelToken,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#deleteUri,
+
[uri],
+
{#data: data, #options: options, #cancelToken: cancelToken},
+
),
+
returnValue: _i9.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#deleteUri,
+
[uri],
+
{#data: data, #options: options, #cancelToken: cancelToken},
+
),
+
),
+
),
+
)
+
as _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i9.Future<_i6.Response<dynamic>> download(
+
String? urlPath,
+
dynamic savePath, {
+
_i2.ProgressCallback? onReceiveProgress,
+
Map<String, dynamic>? queryParameters,
+
_i10.CancelToken? cancelToken,
+
bool? deleteOnError = true,
+
_i2.FileAccessMode? fileAccessMode = _i2.FileAccessMode.write,
+
String? lengthHeader = 'content-length',
+
Object? data,
+
_i2.Options? options,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#download,
+
[urlPath, savePath],
+
{
+
#onReceiveProgress: onReceiveProgress,
+
#queryParameters: queryParameters,
+
#cancelToken: cancelToken,
+
#deleteOnError: deleteOnError,
+
#fileAccessMode: fileAccessMode,
+
#lengthHeader: lengthHeader,
+
#data: data,
+
#options: options,
+
},
+
),
+
returnValue: _i9.Future<_i6.Response<dynamic>>.value(
+
_FakeResponse_4<dynamic>(
+
this,
+
Invocation.method(
+
#download,
+
[urlPath, savePath],
+
{
+
#onReceiveProgress: onReceiveProgress,
+
#queryParameters: queryParameters,
+
#cancelToken: cancelToken,
+
#deleteOnError: deleteOnError,
+
#fileAccessMode: fileAccessMode,
+
#lengthHeader: lengthHeader,
+
#data: data,
+
#options: options,
+
},
+
),
+
),
+
),
+
)
+
as _i9.Future<_i6.Response<dynamic>>);
+
+
@override
+
_i9.Future<_i6.Response<dynamic>> downloadUri(
+
Uri? uri,
+
dynamic savePath, {
+
_i2.ProgressCallback? onReceiveProgress,
+
_i10.CancelToken? cancelToken,
+
bool? deleteOnError = true,
+
_i2.FileAccessMode? fileAccessMode = _i2.FileAccessMode.write,
+
String? lengthHeader = 'content-length',
+
Object? data,
+
_i2.Options? options,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#downloadUri,
+
[uri, savePath],
+
{
+
#onReceiveProgress: onReceiveProgress,
+
#cancelToken: cancelToken,
+
#deleteOnError: deleteOnError,
+
#fileAccessMode: fileAccessMode,
+
#lengthHeader: lengthHeader,
+
#data: data,
+
#options: options,
+
},
+
),
+
returnValue: _i9.Future<_i6.Response<dynamic>>.value(
+
_FakeResponse_4<dynamic>(
+
this,
+
Invocation.method(
+
#downloadUri,
+
[uri, savePath],
+
{
+
#onReceiveProgress: onReceiveProgress,
+
#cancelToken: cancelToken,
+
#deleteOnError: deleteOnError,
+
#fileAccessMode: fileAccessMode,
+
#lengthHeader: lengthHeader,
+
#data: data,
+
#options: options,
+
},
+
),
+
),
+
),
+
)
+
as _i9.Future<_i6.Response<dynamic>>);
+
+
@override
+
_i9.Future<_i6.Response<T>> request<T>(
+
String? url, {
+
Object? data,
+
Map<String, dynamic>? queryParameters,
+
_i10.CancelToken? cancelToken,
+
_i2.Options? options,
+
_i2.ProgressCallback? onSendProgress,
+
_i2.ProgressCallback? onReceiveProgress,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#request,
+
[url],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#cancelToken: cancelToken,
+
#options: options,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
returnValue: _i9.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#request,
+
[url],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#cancelToken: cancelToken,
+
#options: options,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
),
+
),
+
)
+
as _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i9.Future<_i6.Response<T>> requestUri<T>(
+
Uri? uri, {
+
Object? data,
+
_i10.CancelToken? cancelToken,
+
_i2.Options? options,
+
_i2.ProgressCallback? onSendProgress,
+
_i2.ProgressCallback? onReceiveProgress,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#requestUri,
+
[uri],
+
{
+
#data: data,
+
#cancelToken: cancelToken,
+
#options: options,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
returnValue: _i9.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#requestUri,
+
[uri],
+
{
+
#data: data,
+
#cancelToken: cancelToken,
+
#options: options,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
),
+
),
+
)
+
as _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i9.Future<_i6.Response<T>> fetch<T>(_i2.RequestOptions? requestOptions) =>
+
(super.noSuchMethod(
+
Invocation.method(#fetch, [requestOptions]),
+
returnValue: _i9.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(#fetch, [requestOptions]),
+
),
+
),
+
)
+
as _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i7.Dio clone({
+
_i2.BaseOptions? options,
+
_i3.Interceptors? interceptors,
+
_i4.HttpClientAdapter? httpClientAdapter,
+
_i5.Transformer? transformer,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(#clone, [], {
+
#options: options,
+
#interceptors: interceptors,
+
#httpClientAdapter: httpClientAdapter,
+
#transformer: transformer,
+
}),
+
returnValue: _FakeDio_5(
+
this,
+
Invocation.method(#clone, [], {
+
#options: options,
+
#interceptors: interceptors,
+
#httpClientAdapter: httpClientAdapter,
+
#transformer: transformer,
+
}),
+
),
+
)
+
as _i7.Dio);
+
}
+
+
/// A class which mocks [FlutterSecureStorage].
+
///
+
/// See the documentation for Mockito's code generation for more information.
+
class MockFlutterSecureStorage extends _i1.Mock
+
implements _i8.FlutterSecureStorage {
+
MockFlutterSecureStorage() {
+
_i1.throwOnMissingStub(this);
+
}
+
+
@override
+
_i8.IOSOptions get iOptions =>
+
(super.noSuchMethod(
+
Invocation.getter(#iOptions),
+
returnValue: _FakeIOSOptions_6(this, Invocation.getter(#iOptions)),
+
)
+
as _i8.IOSOptions);
+
+
@override
+
_i8.AndroidOptions get aOptions =>
+
(super.noSuchMethod(
+
Invocation.getter(#aOptions),
+
returnValue: _FakeAndroidOptions_7(
+
this,
+
Invocation.getter(#aOptions),
+
),
+
)
+
as _i8.AndroidOptions);
+
+
@override
+
_i8.LinuxOptions get lOptions =>
+
(super.noSuchMethod(
+
Invocation.getter(#lOptions),
+
returnValue: _FakeLinuxOptions_8(
+
this,
+
Invocation.getter(#lOptions),
+
),
+
)
+
as _i8.LinuxOptions);
+
+
@override
+
_i8.WindowsOptions get wOptions =>
+
(super.noSuchMethod(
+
Invocation.getter(#wOptions),
+
returnValue: _FakeWindowsOptions_9(
+
this,
+
Invocation.getter(#wOptions),
+
),
+
)
+
as _i8.WindowsOptions);
+
+
@override
+
_i8.WebOptions get webOptions =>
+
(super.noSuchMethod(
+
Invocation.getter(#webOptions),
+
returnValue: _FakeWebOptions_10(
+
this,
+
Invocation.getter(#webOptions),
+
),
+
)
+
as _i8.WebOptions);
+
+
@override
+
_i8.MacOsOptions get mOptions =>
+
(super.noSuchMethod(
+
Invocation.getter(#mOptions),
+
returnValue: _FakeMacOsOptions_11(
+
this,
+
Invocation.getter(#mOptions),
+
),
+
)
+
as _i8.MacOsOptions);
+
+
@override
+
void registerListener({
+
required String? key,
+
required _i11.ValueChanged<String?>? listener,
+
}) => super.noSuchMethod(
+
Invocation.method(#registerListener, [], {#key: key, #listener: listener}),
+
returnValueForMissingStub: null,
+
);
+
+
@override
+
void unregisterListener({
+
required String? key,
+
required _i11.ValueChanged<String?>? listener,
+
}) => super.noSuchMethod(
+
Invocation.method(#unregisterListener, [], {
+
#key: key,
+
#listener: listener,
+
}),
+
returnValueForMissingStub: null,
+
);
+
+
@override
+
void unregisterAllListenersForKey({required String? key}) =>
+
super.noSuchMethod(
+
Invocation.method(#unregisterAllListenersForKey, [], {#key: key}),
+
returnValueForMissingStub: null,
+
);
+
+
@override
+
void unregisterAllListeners() => super.noSuchMethod(
+
Invocation.method(#unregisterAllListeners, []),
+
returnValueForMissingStub: null,
+
);
+
+
@override
+
_i9.Future<void> write({
+
required String? key,
+
required String? value,
+
_i8.IOSOptions? iOptions,
+
_i8.AndroidOptions? aOptions,
+
_i8.LinuxOptions? lOptions,
+
_i8.WebOptions? webOptions,
+
_i8.MacOsOptions? mOptions,
+
_i8.WindowsOptions? wOptions,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(#write, [], {
+
#key: key,
+
#value: value,
+
#iOptions: iOptions,
+
#aOptions: aOptions,
+
#lOptions: lOptions,
+
#webOptions: webOptions,
+
#mOptions: mOptions,
+
#wOptions: wOptions,
+
}),
+
returnValue: _i9.Future<void>.value(),
+
returnValueForMissingStub: _i9.Future<void>.value(),
+
)
+
as _i9.Future<void>);
+
+
@override
+
_i9.Future<String?> read({
+
required String? key,
+
_i8.IOSOptions? iOptions,
+
_i8.AndroidOptions? aOptions,
+
_i8.LinuxOptions? lOptions,
+
_i8.WebOptions? webOptions,
+
_i8.MacOsOptions? mOptions,
+
_i8.WindowsOptions? wOptions,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(#read, [], {
+
#key: key,
+
#iOptions: iOptions,
+
#aOptions: aOptions,
+
#lOptions: lOptions,
+
#webOptions: webOptions,
+
#mOptions: mOptions,
+
#wOptions: wOptions,
+
}),
+
returnValue: _i9.Future<String?>.value(),
+
)
+
as _i9.Future<String?>);
+
+
@override
+
_i9.Future<bool> containsKey({
+
required String? key,
+
_i8.IOSOptions? iOptions,
+
_i8.AndroidOptions? aOptions,
+
_i8.LinuxOptions? lOptions,
+
_i8.WebOptions? webOptions,
+
_i8.MacOsOptions? mOptions,
+
_i8.WindowsOptions? wOptions,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(#containsKey, [], {
+
#key: key,
+
#iOptions: iOptions,
+
#aOptions: aOptions,
+
#lOptions: lOptions,
+
#webOptions: webOptions,
+
#mOptions: mOptions,
+
#wOptions: wOptions,
+
}),
+
returnValue: _i9.Future<bool>.value(false),
+
)
+
as _i9.Future<bool>);
+
+
@override
+
_i9.Future<void> delete({
+
required String? key,
+
_i8.IOSOptions? iOptions,
+
_i8.AndroidOptions? aOptions,
+
_i8.LinuxOptions? lOptions,
+
_i8.WebOptions? webOptions,
+
_i8.MacOsOptions? mOptions,
+
_i8.WindowsOptions? wOptions,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(#delete, [], {
+
#key: key,
+
#iOptions: iOptions,
+
#aOptions: aOptions,
+
#lOptions: lOptions,
+
#webOptions: webOptions,
+
#mOptions: mOptions,
+
#wOptions: wOptions,
+
}),
+
returnValue: _i9.Future<void>.value(),
+
returnValueForMissingStub: _i9.Future<void>.value(),
+
)
+
as _i9.Future<void>);
+
+
@override
+
_i9.Future<Map<String, String>> readAll({
+
_i8.IOSOptions? iOptions,
+
_i8.AndroidOptions? aOptions,
+
_i8.LinuxOptions? lOptions,
+
_i8.WebOptions? webOptions,
+
_i8.MacOsOptions? mOptions,
+
_i8.WindowsOptions? wOptions,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(#readAll, [], {
+
#iOptions: iOptions,
+
#aOptions: aOptions,
+
#lOptions: lOptions,
+
#webOptions: webOptions,
+
#mOptions: mOptions,
+
#wOptions: wOptions,
+
}),
+
returnValue: _i9.Future<Map<String, String>>.value(
+
<String, String>{},
+
),
+
)
+
as _i9.Future<Map<String, String>>);
+
+
@override
+
_i9.Future<void> deleteAll({
+
_i8.IOSOptions? iOptions,
+
_i8.AndroidOptions? aOptions,
+
_i8.LinuxOptions? lOptions,
+
_i8.WebOptions? webOptions,
+
_i8.MacOsOptions? mOptions,
+
_i8.WindowsOptions? wOptions,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(#deleteAll, [], {
+
#iOptions: iOptions,
+
#aOptions: aOptions,
+
#lOptions: lOptions,
+
#webOptions: webOptions,
+
#mOptions: mOptions,
+
#wOptions: wOptions,
+
}),
+
returnValue: _i9.Future<void>.value(),
+
returnValueForMissingStub: _i9.Future<void>.value(),
+
)
+
as _i9.Future<void>);
+
+
@override
+
_i9.Future<bool?> isCupertinoProtectedDataAvailable() =>
+
(super.noSuchMethod(
+
Invocation.method(#isCupertinoProtectedDataAvailable, []),
+
returnValue: _i9.Future<bool?>.value(),
+
)
+
as _i9.Future<bool?>);
+
}
+17 -56
lib/config/oauth_config.dart
···
-
import 'package:atproto_oauth_flutter/atproto_oauth_flutter.dart';
-
-
import 'environment_config.dart';
-
-
/// OAuth Configuration for atProto
+
/// OAuth Configuration for Coves Backend OAuth
+
///
+
/// This configuration supports the backend's mobile OAuth flow.
+
/// The backend handles all OAuth complexity (PKCE, DPoP, token exchange).
///
-
/// This configuration provides ClientMetadata for the new
-
/// atproto_oauth_flutter package. The new package handles proper
-
/// decentralized OAuth discovery (works with ANY PDS).
+
/// Uses private-use URI scheme per atproto spec (RFC 8252):
+
/// - Format: social.coves:/callback (single slash!)
+
/// - Works on both Android and iOS without Universal Links complexity
class OAuthConfig {
-
// OAuth Server Configuration
-
// Cloudflare Worker that hosts client-metadata.json and handles OAuth
-
// callbacks
-
static const String oauthServerUrl =
-
'https://lingering-darkness-50a6.brettmay0212.workers.dev';
-
// Custom URL scheme for deep linking
-
// Must match AndroidManifest.xml intent filters
-
// Using the same format as working Expo implementation
-
static const String customScheme =
-
'dev.workers.brettmay0212.lingering-darkness-50a6';
-
-
// API Configuration
-
// Environment-aware API URL
-
static String get apiUrl => EnvironmentConfig.current.apiUrl;
+
// Must match AndroidManifest.xml and Info.plist
+
// Uses reverse domain format per atproto spec
+
static const String customScheme = 'social.coves';
-
// Derived OAuth URLs
-
static const String clientId = '$oauthServerUrl/client-metadata.json';
+
// Redirect URI using private-use URI scheme (RFC 8252)
+
// IMPORTANT: Single slash after scheme per RFC 8252!
+
static const String _redirectUri = '$customScheme:/callback';
-
// IMPORTANT: Private-use URI schemes (RFC 8252) require SINGLE slash,
-
// not double!
-
// Correct: dev.workers.example:/oauth/callback
-
// Incorrect: dev.workers.example://oauth/callback
-
static const String customSchemeCallback = '$customScheme:/oauth/callback';
+
/// Get the redirect URI (same for all environments)
+
static String get redirectUri => _redirectUri;
-
// HTTPS callback (fallback for PDS that don't support custom
-
// URI schemes)
-
static const String httpsCallback = '$oauthServerUrl/oauth/callback';
+
/// Get the callback scheme for FlutterWebAuth2
+
static String get callbackScheme => customScheme;
// OAuth Scopes - recommended scope for atProto
static const String scope = 'atproto transition:generic';
// Client name for display during authorization
static const String clientName = 'Coves';
-
-
/// Create ClientMetadata for the FlutterOAuthClient
-
///
-
/// This configures the OAuth client with:
-
/// - Discoverable client ID (HTTPS URL to metadata JSON)
-
/// - HTTPS callback (primary - works with all PDS servers)
-
/// - Custom URL scheme (fallback - requires PDS support)
-
/// - DPoP enabled for token security
-
/// - Proper scopes for atProto access
-
static ClientMetadata createClientMetadata() {
-
return const ClientMetadata(
-
clientId: clientId,
-
// Use HTTPS as PRIMARY - prevents browser re-navigation that
-
// invalidates auth codes. Custom scheme as fallback (Worker page
-
// redirects to custom scheme anyway)
-
redirectUris: [httpsCallback, customSchemeCallback],
-
scope: scope,
-
clientName: clientName,
-
dpopBoundAccessTokens: true, // Enable DPoP for security
-
applicationType: 'native',
-
grantTypes: ['authorization_code', 'refresh_token'],
-
tokenEndpointAuthMethod: 'none', // Public client (mobile apps)
-
);
-
}
}
+98 -155
lib/providers/auth_provider.dart
···
-
import 'package:atproto_oauth_flutter/atproto_oauth_flutter.dart';
import 'package:flutter/foundation.dart';
-
import 'package:shared_preferences/shared_preferences.dart';
-
import '../services/oauth_service.dart';
+
import '../models/coves_session.dart';
+
import '../services/coves_auth_service.dart';
/// Authentication Provider
///
-
/// Manages authentication state using the new atproto_oauth_flutter package.
+
/// Manages authentication state using the Coves backend OAuth flow.
/// Uses ChangeNotifier for reactive state updates across the app.
///
-
/// Key improvements:
-
/// โœ… Uses OAuthSession from the new package (with built-in token management)
-
/// โœ… Stores only the DID in SharedPreferences (public info, not sensitive)
-
/// โœ… Tokens are stored securely by the package (iOS Keychain / Android EncryptedSharedPreferences)
-
/// โœ… Automatic token refresh handled by the package
+
/// Key features:
+
/// - Uses CovesAuthService for backend-managed OAuth
+
/// - Tokens are sealed (AES-256-GCM encrypted) and opaque to the client
+
/// - Backend handles DPoP, PKCE, and token refresh internally
+
/// - Session stored securely (iOS Keychain / Android EncryptedSharedPreferences)
class AuthProvider with ChangeNotifier {
-
/// Constructor with optional OAuthService for dependency injection (testing)
-
AuthProvider({OAuthService? oauthService})
-
: _oauthService = oauthService ?? OAuthService();
-
final OAuthService _oauthService;
-
-
// SharedPreferences keys for storing session info
-
// The DID and handle are public information, so SharedPreferences is fine
-
// The actual tokens are stored securely by the atproto_oauth_flutter package
-
static const String _prefKeyDid = 'current_user_did';
-
static const String _prefKeyHandle = 'current_user_handle';
+
/// Constructor with optional auth service for dependency injection
+
AuthProvider({CovesAuthService? authService})
+
: _authService = authService ?? CovesAuthService();
+
final CovesAuthService _authService;
// Session state
-
OAuthSession? _session;
+
CovesSession? _session;
bool _isAuthenticated = false;
bool _isLoading = true;
String? _error;
-
// User info
-
String? _did;
-
String? _handle;
-
// Getters
-
OAuthSession? get session => _session;
+
CovesSession? get session => _session;
bool get isAuthenticated => _isAuthenticated;
bool get isLoading => _isLoading;
String? get error => _error;
-
String? get did => _did;
-
String? get handle => _handle;
+
String? get did => _session?.did;
+
String? get handle => _session?.handle;
-
/// Get the current access token
+
/// Get the current access token (sealed token)
///
-
/// This fetches the token from the session's token set.
-
/// The token is automatically refreshed if expired.
-
/// If token refresh fails (e.g., revoked server-side), signs out the user.
-
Future<String?> getAccessToken() async {
-
if (_session == null) {
-
return null;
-
}
-
-
try {
-
// Access the session getter to get the token set
-
final session = await _session!.sessionGetter.get(_session!.sub);
-
return session.tokenSet.accessToken;
-
} on Exception catch (e) {
-
if (kDebugMode) {
-
print('โŒ Failed to get access token: $e');
-
print('๐Ÿ”„ Token refresh failed - signing out user');
-
}
-
-
// Token refresh failed (likely revoked or expired beyond refresh)
-
// Sign out user to clear invalid session
-
await signOut();
-
return null;
-
}
-
}
-
-
/// Get the user's PDS URL
-
///
-
/// Returns the URL of the user's Personal Data Server from the OAuth session.
-
/// This is needed for direct XRPC calls to the PDS (e.g., createRecord).
+
/// Returns the sealed token for API authentication.
+
/// The token is opaque to the client - backend handles everything.
///
-
/// The PDS URL is stored in serverMetadata['issuer'] from the OAuth session.
-
String? getPdsUrl() {
+
/// If token refresh fails, attempts to refresh automatically.
+
/// If refresh fails, signs out the user.
+
Future<String?> getAccessToken() async {
if (_session == null) {
return null;
}
-
return _session!.serverMetadata['issuer'] as String?;
+
// Return the sealed token directly
+
// Token refresh is handled by the backend when the token is used
+
return _session!.token;
}
/// Initialize the provider and restore any existing session
///
/// This is called on app startup to:
-
/// 1. Initialize the OAuth service
-
/// 2. Check if there's a stored DID (from previous session)
-
/// 3. Restore the session if found (with automatic token refresh)
+
/// 1. Initialize the auth service
+
/// 2. Restore session from secure storage if available
Future<void> initialize() async {
try {
_isLoading = true;
_error = null;
notifyListeners();
-
// Initialize OAuth service
-
await _oauthService.initialize();
+
// Initialize auth service
+
await _authService.initialize();
-
// Check if we have a stored DID from a previous session
-
final prefs = await SharedPreferences.getInstance();
-
final storedDid = prefs.getString(_prefKeyDid);
-
final storedHandle = prefs.getString(_prefKeyHandle);
+
// Try to restore a previous session from secure storage
+
final restoredSession = await _authService.restoreSession();
-
if (storedDid != null) {
-
if (kDebugMode) {
-
print('Found stored DID: $storedDid');
-
print('Found stored handle: $storedHandle');
-
}
+
if (restoredSession != null) {
+
_session = restoredSession;
+
_isAuthenticated = true;
-
// Try to restore the session
-
// The package will automatically refresh tokens if needed
-
final restoredSession = await _oauthService.restoreSession(storedDid);
-
-
if (restoredSession != null) {
-
_session = restoredSession;
-
_isAuthenticated = true;
-
_did = restoredSession.sub;
-
_handle = storedHandle; // Restore handle from preferences
-
-
if (kDebugMode) {
-
print('โœ… Successfully restored session');
-
print(' DID: ${restoredSession.sub}');
-
print(' Handle: $storedHandle');
-
}
-
} else {
-
// Failed to restore - clear the stored data
-
await prefs.remove(_prefKeyDid);
-
await prefs.remove(_prefKeyHandle);
-
if (kDebugMode) {
-
print('โš ๏ธ Could not restore session - cleared stored data');
-
}
+
if (kDebugMode) {
+
print('Restored session');
+
print(' DID: ${restoredSession.did}');
+
print(' Handle: ${restoredSession.handle}');
}
} else {
if (kDebugMode) {
-
print('No stored DID found - user not logged in');
+
print('No stored session found - user not logged in');
}
}
-
} on Exception catch (e) {
+
} catch (e) {
+
// Catch all errors to prevent app crashes during initialization
_error = e.toString();
if (kDebugMode) {
-
print('โŒ Failed to initialize auth: $e');
+
print('Failed to initialize auth: $e');
}
} finally {
_isLoading = false;
···
/// Sign in with an atProto handle
///
-
/// This works with ANY handle on ANY PDS:
-
/// - alice.bsky.social โ†’ Bluesky PDS
-
/// - bob.custom-pds.com โ†’ Custom PDS
-
/// - did:plc:abc123 โ†’ Direct DID
-
///
-
/// The package handles:
-
/// - Handle โ†’ DID resolution
+
/// Opens the system browser to the backend's OAuth endpoint.
+
/// The backend handles:
+
/// - Handle -> DID resolution
/// - PDS discovery
-
/// - OAuth authorization
-
/// - Token storage
+
/// - OAuth authorization with PKCE/DPoP
+
/// - Token sealing
+
///
+
/// Works with ANY handle on ANY PDS:
+
/// - alice.bsky.social -> Bluesky PDS
+
/// - bob.custom-pds.com -> Custom PDS
+
/// - did:plc:abc123 -> Direct DID
Future<void> signIn(String handle) async {
try {
_isLoading = true;
···
throw Exception('Please enter a valid handle');
}
-
// Perform OAuth sign in with the new package
-
final session = await _oauthService.signIn(trimmedHandle);
+
// Perform OAuth sign in via backend
+
final session = await _authService.signIn(trimmedHandle);
// Update state
_session = session;
_isAuthenticated = true;
-
_did = session.sub;
-
_handle = trimmedHandle;
-
-
// Store the DID and handle in SharedPreferences so we can restore
-
// on next launch
-
final prefs = await SharedPreferences.getInstance();
-
await prefs.setString(_prefKeyDid, session.sub);
-
await prefs.setString(_prefKeyHandle, trimmedHandle);
if (kDebugMode) {
-
print('โœ… Successfully signed in');
-
print(' Handle: $trimmedHandle');
-
print(' DID: ${session.sub}');
+
print('Successfully signed in');
+
print(' Handle: ${session.handle ?? trimmedHandle}');
+
print(' DID: ${session.did}');
}
} catch (e) {
_error = e.toString();
_isAuthenticated = false;
_session = null;
-
_did = null;
-
_handle = null;
if (kDebugMode) {
-
print('โŒ Sign in failed: $e');
+
print('Sign in failed: $e');
}
rethrow;
···
/// Sign out and clear session
///
/// This:
-
/// 1. Calls the server's token revocation endpoint (best-effort)
-
/// 2. Deletes session from secure storage
-
/// 3. Clears the stored DID from SharedPreferences
-
/// 4. Resets the provider state
+
/// 1. Calls the backend's logout endpoint (revokes session server-side)
+
/// 2. Clears session from secure storage
+
/// 3. Resets the provider state
Future<void> signOut() async {
try {
_isLoading = true;
notifyListeners();
-
// Get the current DID before clearing state
-
final currentDid = _did;
-
-
if (currentDid != null) {
-
// Call the new package's revoke method
-
// This handles server-side revocation + local storage cleanup
-
await _oauthService.signOut(currentDid);
-
}
-
-
// Clear the stored DID and handle from SharedPreferences
-
final prefs = await SharedPreferences.getInstance();
-
await prefs.remove(_prefKeyDid);
-
await prefs.remove(_prefKeyHandle);
+
// Call auth service signOut (handles server + local cleanup)
+
await _authService.signOut();
// Clear state
_session = null;
_isAuthenticated = false;
-
_did = null;
-
_handle = null;
_error = null;
if (kDebugMode) {
-
print('โœ… Successfully signed out');
+
print('Successfully signed out');
}
} on Exception catch (e) {
_error = e.toString();
if (kDebugMode) {
-
print('โš ๏ธ Sign out failed: $e');
+
print('Sign out failed: $e');
}
-
// Even if revocation fails, clear local state
+
// Even if server revocation fails, clear local state
_session = null;
_isAuthenticated = false;
-
_did = null;
-
_handle = null;
} finally {
_isLoading = false;
notifyListeners();
}
}
+
/// Refresh the current session token
+
///
+
/// Calls the backend's /oauth/refresh endpoint.
+
/// The backend handles the actual PDS token refresh internally.
+
///
+
/// Returns true if refresh succeeded, false otherwise.
+
Future<bool> refreshToken() async {
+
if (_session == null) {
+
return false;
+
}
+
+
try {
+
final refreshedSession = await _authService.refreshToken();
+
_session = refreshedSession;
+
notifyListeners();
+
+
if (kDebugMode) {
+
print('Token refreshed successfully');
+
}
+
+
return true;
+
} on Exception catch (e) {
+
if (kDebugMode) {
+
print('Token refresh failed: $e');
+
}
+
+
// If refresh fails, sign out the user
+
await signOut();
+
return false;
+
}
+
}
+
/// Clear error message
void clearError() {
_error = null;
notifyListeners();
}
-
-
/// Dispose resources
-
@override
-
void dispose() {
-
_oauthService.dispose();
-
super.dispose();
-
}
}
+5 -4
test/services/coves_api_service_test.dart
···
import 'package:coves_flutter/models/comment.dart';
+
import 'package:coves_flutter/services/api_exceptions.dart';
import 'package:coves_flutter/services/coves_api_service.dart';
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
···
expect(
() => apiService.getComments(postUri: postUri),
-
throwsA(isA<DioException>()),
+
throwsA(isA<NetworkException>()),
);
});
···
expect(
() => apiService.getComments(postUri: postUri),
-
throwsA(isA<DioException>()),
+
throwsA(isA<NetworkException>()),
);
});
···
expect(
() => apiService.getComments(postUri: postUri),
-
throwsA(isA<Exception>()),
+
throwsA(isA<ApiException>()),
);
});
···
expect(
() => apiService.getComments(postUri: postUri),
-
throwsA(isA<Exception>()),
+
throwsA(isA<ApiException>()),
);
});
-158
test/services/vote_service_test.mocks.dart
···
-
// Mocks generated by Mockito 5.4.6 from annotations
-
// in coves_flutter/test/services/vote_service_test.dart.
-
// Do not manually edit this file.
-
-
// ignore_for_file: no_leading_underscores_for_library_prefixes
-
import 'dart:async' as _i6;
-
-
import 'package:atproto_oauth_flutter/src/oauth/oauth_server_agent.dart' as _i2;
-
import 'package:atproto_oauth_flutter/src/session/oauth_session.dart' as _i3;
-
import 'package:http/http.dart' as _i4;
-
import 'package:mockito/mockito.dart' as _i1;
-
import 'package:mockito/src/dummies.dart' as _i5;
-
-
// ignore_for_file: type=lint
-
// ignore_for_file: avoid_redundant_argument_values
-
// ignore_for_file: avoid_setters_without_getters
-
// ignore_for_file: comment_references
-
// ignore_for_file: deprecated_member_use
-
// ignore_for_file: deprecated_member_use_from_same_package
-
// ignore_for_file: implementation_imports
-
// ignore_for_file: invalid_use_of_visible_for_testing_member
-
// ignore_for_file: must_be_immutable
-
// ignore_for_file: prefer_const_constructors
-
// ignore_for_file: unnecessary_parenthesis
-
// ignore_for_file: camel_case_types
-
// ignore_for_file: subtype_of_sealed_class
-
// ignore_for_file: invalid_use_of_internal_member
-
-
class _FakeOAuthServerAgent_0 extends _i1.SmartFake
-
implements _i2.OAuthServerAgent {
-
_FakeOAuthServerAgent_0(Object parent, Invocation parentInvocation)
-
: super(parent, parentInvocation);
-
}
-
-
class _FakeSessionGetterInterface_1 extends _i1.SmartFake
-
implements _i3.SessionGetterInterface {
-
_FakeSessionGetterInterface_1(Object parent, Invocation parentInvocation)
-
: super(parent, parentInvocation);
-
}
-
-
class _FakeTokenInfo_2 extends _i1.SmartFake implements _i3.TokenInfo {
-
_FakeTokenInfo_2(Object parent, Invocation parentInvocation)
-
: super(parent, parentInvocation);
-
}
-
-
class _FakeResponse_3 extends _i1.SmartFake implements _i4.Response {
-
_FakeResponse_3(Object parent, Invocation parentInvocation)
-
: super(parent, parentInvocation);
-
}
-
-
/// A class which mocks [OAuthSession].
-
///
-
/// See the documentation for Mockito's code generation for more information.
-
class MockOAuthSession extends _i1.Mock implements _i3.OAuthSession {
-
MockOAuthSession() {
-
_i1.throwOnMissingStub(this);
-
}
-
-
@override
-
_i2.OAuthServerAgent get server =>
-
(super.noSuchMethod(
-
Invocation.getter(#server),
-
returnValue: _FakeOAuthServerAgent_0(
-
this,
-
Invocation.getter(#server),
-
),
-
)
-
as _i2.OAuthServerAgent);
-
-
@override
-
String get sub =>
-
(super.noSuchMethod(
-
Invocation.getter(#sub),
-
returnValue: _i5.dummyValue<String>(this, Invocation.getter(#sub)),
-
)
-
as String);
-
-
@override
-
_i3.SessionGetterInterface get sessionGetter =>
-
(super.noSuchMethod(
-
Invocation.getter(#sessionGetter),
-
returnValue: _FakeSessionGetterInterface_1(
-
this,
-
Invocation.getter(#sessionGetter),
-
),
-
)
-
as _i3.SessionGetterInterface);
-
-
@override
-
String get did =>
-
(super.noSuchMethod(
-
Invocation.getter(#did),
-
returnValue: _i5.dummyValue<String>(this, Invocation.getter(#did)),
-
)
-
as String);
-
-
@override
-
Map<String, dynamic> get serverMetadata =>
-
(super.noSuchMethod(
-
Invocation.getter(#serverMetadata),
-
returnValue: <String, dynamic>{},
-
)
-
as Map<String, dynamic>);
-
-
@override
-
_i6.Future<_i3.TokenInfo> getTokenInfo([dynamic refresh = 'auto']) =>
-
(super.noSuchMethod(
-
Invocation.method(#getTokenInfo, [refresh]),
-
returnValue: _i6.Future<_i3.TokenInfo>.value(
-
_FakeTokenInfo_2(
-
this,
-
Invocation.method(#getTokenInfo, [refresh]),
-
),
-
),
-
)
-
as _i6.Future<_i3.TokenInfo>);
-
-
@override
-
_i6.Future<void> signOut() =>
-
(super.noSuchMethod(
-
Invocation.method(#signOut, []),
-
returnValue: _i6.Future<void>.value(),
-
returnValueForMissingStub: _i6.Future<void>.value(),
-
)
-
as _i6.Future<void>);
-
-
@override
-
_i6.Future<_i4.Response> fetchHandler(
-
String? pathname, {
-
String? method = 'GET',
-
Map<String, String>? headers,
-
dynamic body,
-
}) =>
-
(super.noSuchMethod(
-
Invocation.method(
-
#fetchHandler,
-
[pathname],
-
{#method: method, #headers: headers, #body: body},
-
),
-
returnValue: _i6.Future<_i4.Response>.value(
-
_FakeResponse_3(
-
this,
-
Invocation.method(
-
#fetchHandler,
-
[pathname],
-
{#method: method, #headers: headers, #body: body},
-
),
-
),
-
),
-
)
-
as _i6.Future<_i4.Response>);
-
-
@override
-
void dispose() => super.noSuchMethod(
-
Invocation.method(#dispose, []),
-
returnValueForMissingStub: null,
-
);
-
}
-296
lib/services/oauth_service.dart
···
-
import 'dart:async';
-
import 'package:atproto_oauth_flutter/atproto_oauth_flutter.dart';
-
import 'package:flutter/foundation.dart';
-
import '../config/environment_config.dart';
-
import '../config/oauth_config.dart';
-
-
/// OAuth Service for atProto authentication using the new
-
/// atproto_oauth_flutter package
-
///
-
/// Key improvements over the old implementation:
-
/// โœ… Proper decentralized OAuth discovery - works with ANY PDS
-
/// (not just bsky.social)
-
/// โœ… Built-in session management - no manual token storage
-
/// โœ… Automatic token refresh with concurrency control
-
/// โœ… Session event streams for updates and deletions
-
/// โœ… Secure storage handled internally
-
/// (iOS Keychain, Android EncryptedSharedPreferences)
-
///
-
/// The new package handles the complete OAuth flow:
-
/// 1. Handle/DID resolution
-
/// 2. PDS discovery from DID document
-
/// 3. Authorization server discovery
-
/// 4. PKCE + DPoP generation
-
/// 5. Browser-based authorization
-
/// 6. Token exchange and storage
-
/// 7. Automatic refresh and revocation
-
class OAuthService {
-
factory OAuthService() => _instance;
-
OAuthService._internal();
-
static final OAuthService _instance = OAuthService._internal();
-
-
FlutterOAuthClient? _client;
-
-
// Session event stream subscriptions
-
StreamSubscription<SessionUpdatedEvent>? _onUpdatedSubscription;
-
StreamSubscription<SessionDeletedEvent>? _onDeletedSubscription;
-
-
/// Initialize the OAuth client
-
///
-
/// This creates a FlutterOAuthClient with:
-
/// - Discoverable client metadata (HTTPS URL)
-
/// - Custom URL scheme for deep linking
-
/// - DPoP enabled for token security
-
/// - Automatic session management
-
Future<void> initialize() async {
-
try {
-
// Get environment configuration
-
final config = EnvironmentConfig.current;
-
-
// Create client with metadata from config
-
// For local development, use custom resolvers
-
_client = FlutterOAuthClient(
-
clientMetadata: OAuthConfig.createClientMetadata(),
-
plcDirectoryUrl: config.plcDirectoryUrl,
-
handleResolverUrl: config.handleResolverUrl,
-
allowHttp: config.isLocal, // Allow HTTP for local development
-
);
-
-
// Set up session event listeners
-
_setupEventListeners();
-
-
if (kDebugMode) {
-
print('โœ… FlutterOAuthClient initialized');
-
print(' Environment: ${config.environment}');
-
print(' Client ID: ${OAuthConfig.clientId}');
-
print(' Redirect URI: ${OAuthConfig.customSchemeCallback}');
-
print(' Scope: ${OAuthConfig.scope}');
-
print(' Handle Resolver: ${config.handleResolverUrl}');
-
print(' PLC Directory: ${config.plcDirectoryUrl}');
-
print(' Allow HTTP: ${config.isLocal}');
-
}
-
} catch (e) {
-
if (kDebugMode) {
-
print('โŒ Failed to initialize OAuth client: $e');
-
}
-
rethrow;
-
}
-
}
-
-
/// Set up listeners for session events
-
void _setupEventListeners() {
-
if (_client == null) {
-
return;
-
}
-
-
// Listen for session updates (token refresh, etc.)
-
_onUpdatedSubscription = _client!.onUpdated.listen((event) {
-
if (kDebugMode) {
-
print('๐Ÿ“ Session updated for: ${event.sub}');
-
}
-
});
-
-
// Listen for session deletions (revoke, expiry, errors)
-
_onDeletedSubscription = _client!.onDeleted.listen((event) {
-
if (kDebugMode) {
-
print('๐Ÿ—‘๏ธ Session deleted for: ${event.sub}');
-
print(' Cause: ${event.cause}');
-
}
-
});
-
}
-
-
/// Sign in with an atProto handle
-
///
-
/// The new package handles the complete OAuth flow:
-
/// 1. Resolves handle โ†’ DID (using any handle resolver)
-
/// 2. Fetches DID document to find the user's PDS
-
/// 3. Discovers authorization server from PDS metadata
-
/// 4. Generates PKCE challenge and DPoP keys
-
/// 5. Opens browser for user authorization
-
/// 6. Handles callback and exchanges code for tokens
-
/// 7. Stores session securely (iOS Keychain / Android EncryptedSharedPreferences)
-
///
-
/// This works with ANY PDS - not just bsky.social! ๐ŸŽ‰
-
///
-
/// Examples:
-
/// - `signIn('alice.bsky.social')` โ†’ Bluesky PDS
-
/// - `signIn('bob.custom-pds.com')` โ†’ Custom PDS โœ…
-
/// - `signIn('did:plc:abc123')` โ†’ Direct DID (skips handle resolution)
-
///
-
/// Returns the authenticated OAuthSession.
-
Future<OAuthSession> signIn(String input) async {
-
try {
-
if (_client == null) {
-
throw Exception(
-
'OAuth client not initialized. Call initialize() first.',
-
);
-
}
-
-
// Validate input
-
final trimmedInput = input.trim();
-
if (trimmedInput.isEmpty) {
-
throw Exception('Please enter a valid handle or DID');
-
}
-
-
if (kDebugMode) {
-
print('๐Ÿ” Starting sign-in for: $trimmedInput');
-
print('โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”');
-
}
-
-
// Call the new package's signIn method
-
// This does EVERYTHING: handle resolution, PDS discovery, OAuth flow,
-
// token storage
-
if (kDebugMode) {
-
print('๐Ÿ“ž Calling FlutterOAuthClient.signIn()...');
-
}
-
-
final session = await _client!.signIn(trimmedInput);
-
-
if (kDebugMode) {
-
print('โœ… Sign-in successful!');
-
print(' DID: ${session.sub}');
-
print(' PDS: ${session.serverMetadata['issuer'] ?? 'unknown'}');
-
print('โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”');
-
}
-
-
return session;
-
} on OAuthCallbackError catch (e, stackTrace) {
-
// OAuth-specific errors (access denied, invalid request, etc.)
-
final errorCode = e.params['error'];
-
final errorDescription = e.params['error_description'] ?? e.message;
-
-
if (kDebugMode) {
-
print('โŒ OAuth callback error details:');
-
print(' Error code: $errorCode');
-
print(' Description: $errorDescription');
-
print(' Message: ${e.message}');
-
print(' All params: ${e.params}');
-
print(' Exception type: ${e.runtimeType}');
-
print(' Exception: $e');
-
print(' Stack trace:');
-
print('$stackTrace');
-
}
-
-
if (errorCode == 'access_denied') {
-
throw Exception('Sign in cancelled by user');
-
}
-
-
throw Exception('OAuth error: $errorDescription');
-
} catch (e, stackTrace) {
-
// Catch all other errors including user cancellation
-
if (kDebugMode) {
-
print('โŒ Sign in failed - detailed error:');
-
print(' Error type: ${e.runtimeType}');
-
print(' Error: $e');
-
print(' Stack trace:');
-
print('$stackTrace');
-
}
-
-
// Check if user cancelled (flutter_web_auth_2 throws
-
// PlatformException with "CANCELED" code)
-
if (e.toString().contains('CANCELED') ||
-
e.toString().contains('User cancelled')) {
-
throw Exception('Sign in cancelled by user');
-
}
-
-
throw Exception('Sign in failed: $e');
-
}
-
}
-
-
/// Restore a previous session if available
-
///
-
/// The new package handles session restoration automatically:
-
/// - Loads session from secure storage
-
/// - Checks token expiration
-
/// - Automatically refreshes if needed
-
/// - Returns null if no valid session exists
-
///
-
/// Parameters:
-
/// - `did`: User's DID (e.g., "did:plc:abc123")
-
/// - `refresh`: Token refresh strategy:
-
/// - 'auto' (default): Refresh only if expired
-
/// - true: Force refresh even if not expired
-
/// - false: Use cached tokens even if expired
-
///
-
/// Returns the restored session or null if no session found.
-
Future<OAuthSession?> restoreSession(
-
String did, {
-
String refresh = 'auto',
-
}) async {
-
try {
-
if (_client == null) {
-
throw Exception(
-
'OAuth client not initialized. Call initialize() first.',
-
);
-
}
-
-
if (kDebugMode) {
-
print('๐Ÿ”„ Attempting to restore session for: $did');
-
}
-
-
// Call the new package's restore method
-
final session = await _client!.restore(did, refresh: refresh);
-
-
if (kDebugMode) {
-
print('โœ… Session restored successfully');
-
final info = await session.getTokenInfo();
-
print(' Token expires: ${info.expiresAt}');
-
}
-
-
return session;
-
} on Exception catch (e) {
-
if (kDebugMode) {
-
print('โš ๏ธ Failed to restore session: $e');
-
}
-
return null;
-
}
-
}
-
-
/// Sign out and revoke session
-
///
-
/// The new package handles revocation properly:
-
/// - Calls server's token revocation endpoint (best-effort)
-
/// - Deletes session from secure storage (always)
-
/// - Emits 'deleted' event
-
///
-
/// This is a complete sign-out with server-side revocation! ๐ŸŽ‰
-
Future<void> signOut(String did) async {
-
try {
-
if (_client == null) {
-
throw Exception(
-
'OAuth client not initialized. Call initialize() first.',
-
);
-
}
-
-
if (kDebugMode) {
-
print('๐Ÿ‘‹ Signing out: $did');
-
}
-
-
// Call the new package's revoke method
-
await _client!.revoke(did);
-
-
if (kDebugMode) {
-
print('โœ… Sign out successful');
-
}
-
} catch (e) {
-
if (kDebugMode) {
-
print('โš ๏ธ Sign out failed: $e');
-
}
-
// Re-throw to let caller handle
-
rethrow;
-
}
-
}
-
-
/// Get the current OAuth client instance
-
///
-
/// Useful for advanced use cases like:
-
/// - Listening to session events directly
-
/// - Using lower-level OAuth methods
-
FlutterOAuthClient? get client => _client;
-
-
/// Clean up resources
-
void dispose() {
-
_onUpdatedSubscription?.cancel();
-
_onDeletedSubscription?.cancel();
-
}
-
}
-337
packages/atproto_oauth_flutter/CHUNK3_IMPLEMENTATION_REPORT.md
···
-
# Chunk 3 Implementation Report: Identity Resolution Layer
-
-
## Status: โœ… COMPLETE
-
-
Implementation Date: 2025-10-27
-
Implementation Time: ~2 hours
-
Lines of Code: ~1,431 lines across 9 Dart files
-
-
## Overview
-
-
Successfully ported the **atProto Identity Resolution Layer** from TypeScript to Dart with full 1:1 API compatibility. This is the **most critical component for atProto decentralization**, enabling users to host their data on any Personal Data Server (PDS) instead of being locked to bsky.social.
-
-
## What Was Implemented
-
-
### Core Files Created
-
-
```
-
lib/src/identity/
-
โ”œโ”€โ”€ constants.dart (30 lines) - atProto constants
-
โ”œโ”€โ”€ did_document.dart (124 lines) - DID document parsing
-
โ”œโ”€โ”€ did_helpers.dart (227 lines) - DID validation utilities
-
โ”œโ”€โ”€ did_resolver.dart (269 lines) - DID โ†’ Document resolution
-
โ”œโ”€โ”€ handle_helpers.dart (31 lines) - Handle validation
-
โ”œโ”€โ”€ handle_resolver.dart (209 lines) - Handle โ†’ DID resolution
-
โ”œโ”€โ”€ identity_resolver.dart (378 lines) - Main resolver (orchestrates everything)
-
โ”œโ”€โ”€ identity_resolver_error.dart (53 lines) - Error types
-
โ”œโ”€โ”€ identity.dart (43 lines) - Public API exports
-
โ””โ”€โ”€ README.md (267 lines) - Comprehensive documentation
-
```
-
-
### Additional Files
-
-
```
-
test/identity_resolver_test.dart (231 lines) - 21 passing unit tests
-
example/identity_resolver_example.dart (95 lines) - Usage examples
-
```
-
-
## Critical Functionality Implemented
-
-
### 1. Handle Resolution (Handle โ†’ DID)
-
-
Resolves atProto handles like `alice.bsky.social` to DIDs using XRPC:
-
-
```dart
-
final resolver = XrpcHandleResolver('https://bsky.social');
-
final did = await resolver.resolve('alice.bsky.social');
-
// Returns: did:plc:...
-
```
-
-
**Features:**
-
- XRPC-based resolution via `com.atproto.identity.resolveHandle`
-
- Proper error handling for invalid/non-existent handles
-
- Built-in caching with configurable TTL (1 hour default)
-
- Validates DIDs are proper atProto DIDs (plc or web)
-
-
### 2. DID Resolution (DID โ†’ DID Document)
-
-
Fetches DID documents from PLC directory or HTTPS:
-
-
```dart
-
final resolver = AtprotoDidResolver();
-
-
// Resolve did:plc from PLC directory
-
final doc = await resolver.resolve('did:plc:z72i7hdynmk6r22z27h6abc2');
-
-
// Resolve did:web via HTTPS
-
final doc2 = await resolver.resolve('did:web:example.com');
-
```
-
-
**Features:**
-
- `did:plc` method: Queries https://plc.directory/
-
- `did:web` method: Fetches from HTTPS URLs (/.well-known/did.json or /did.json)
-
- Validates DID document structure
-
- Caching with 24-hour default TTL
-
- No HTTP redirects (security)
-
-
### 3. Identity Resolution (Handle/DID โ†’ Complete Info)
-
-
Main resolver that orchestrates everything:
-
-
```dart
-
final resolver = AtprotoIdentityResolver.withDefaults(
-
handleResolverUrl: 'https://bsky.social',
-
);
-
-
// Resolve handle to full identity info
-
final info = await resolver.resolve('alice.bsky.social');
-
print('DID: ${info.did}');
-
print('Handle: ${info.handle}');
-
print('PDS: ${info.pdsUrl}');
-
-
// Or resolve directly to PDS URL (most common use case)
-
final pdsUrl = await resolver.resolveToPds('alice.bsky.social');
-
```
-
-
**Features:**
-
- Accepts both handles and DIDs as input
-
- Enforces bi-directional validation (security)
-
- Extracts PDS URL from DID document
-
- Validates handle in DID document matches original
-
- Complete error handling with specific error types
-
- Configurable caching at all layers
-
-
### 4. Bi-directional Validation (CRITICAL for Security)
-
-
For every resolution, we validate both directions:
-
-
1. **Handle โ†’ DID** resolution succeeds
-
2. **DID Document** contains the original handle
-
3. **Both directions** agree
-
-
This prevents:
-
- Handle hijacking
-
- DID spoofing
-
- MITM attacks
-
-
### 5. DID Document Parsing
-
-
Full W3C DID Document support:
-
-
```dart
-
final doc = DidDocument.fromJson(json);
-
-
// Extract atProto-specific info
-
final pdsUrl = doc.extractPdsUrl();
-
final handle = doc.extractNormalizedHandle();
-
-
// Access standard DID doc fields
-
print(doc.id); // DID
-
print(doc.alsoKnownAs); // Alternative identifiers
-
print(doc.service); // Service endpoints
-
```
-
-
### 6. Validation Utilities
-
-
**DID Validation:**
-
- `isDid()` - Checks if string is valid DID
-
- `isDidPlc()` - Validates did:plc format (exactly 32 chars, base32)
-
- `isDidWeb()` - Validates did:web format
-
- `isAtprotoDid()` - Checks if DID uses blessed methods
-
- `assertDid()` - Throws detailed errors for invalid DIDs
-
-
**Handle Validation:**
-
- `isValidHandle()` - Validates handle format per spec
-
- `normalizeHandle()` - Converts to lowercase
-
- `asNormalizedHandle()` - Validates and normalizes
-
-
### 7. Caching Layer
-
-
Two-tier caching system:
-
-
**Handle Cache:**
-
- TTL: 1 hour default (handles can change)
-
- In-memory implementation
-
- Optional `noCache` bypass
-
-
**DID Document Cache:**
-
- TTL: 24 hours default (more stable)
-
- In-memory implementation
-
- Optional `noCache` bypass
-
-
### 8. Error Handling
-
-
Comprehensive error hierarchy:
-
-
```dart
-
IdentityResolverError - Base error
-
โ”œโ”€โ”€ InvalidDidError - Malformed DID
-
โ”œโ”€โ”€ InvalidHandleError - Malformed handle
-
โ”œโ”€โ”€ HandleResolverError - Handle resolution failed
-
โ””โ”€โ”€ DidResolverError - DID resolution failed
-
```
-
-
All errors include:
-
- Detailed error messages
-
- Original cause (if any)
-
- Context about what failed
-
-
## Testing
-
-
### Unit Tests: โœ… 21 tests, all passing
-
-
```bash
-
$ flutter test test/identity_resolver_test.dart
-
All tests passed!
-
```
-
-
**Test Coverage:**
-
- DID validation (did:plc, did:web, general DIDs)
-
- DID method extraction
-
- URL โ†” did:web conversion
-
- Handle validation and normalization
-
- DID document parsing
-
- PDS URL extraction
-
- Handle extraction from DID docs
-
- Cache functionality (store, retrieve, expire)
-
- Error types and messages
-
-
### Static Analysis: โœ… No issues
-
-
```bash
-
$ flutter analyze lib/src/identity/
-
No issues found!
-
```
-
-
## Source Traceability
-
-
This implementation is a 1:1 port from official atProto TypeScript packages:
-
-
**Source Files:**
-
- `/home/bretton/Code/atproto/packages/oauth/oauth-client/src/identity-resolver.ts`
-
- `/home/bretton/Code/atproto/packages/internal/identity-resolver/src/`
-
- `/home/bretton/Code/atproto/packages/internal/did-resolver/src/`
-
- `/home/bretton/Code/atproto/packages/internal/handle-resolver/src/`
-
-
**Key Differences from TypeScript:**
-
1. **No DNS Resolution**: Dart doesn't have built-in DNS TXT lookups, use XRPC only
-
2. **Dio instead of Fetch**: Using Dio HTTP client
-
3. **Explicit Types**: Dart's stricter type system
-
4. **Simplified Caching**: In-memory only (TypeScript has more backends)
-
-
## Why This Is Critical for Decentralization
-
-
### Problem Without This Layer
-
-
Without proper identity resolution:
-
- Apps hardcode `bsky.social` as the only server
-
- Users can't use custom domains
-
- Self-hosting is impossible
-
- atProto becomes centralized like Twitter/X
-
-
### Solution With This Layer
-
-
โœ… **Users host data on any PDS** they choose
-
โœ… **Custom domain handles** work (e.g., `alice.example.com`)
-
โœ… **Identity is portable** (change PDS without losing DID)
-
โœ… **True decentralization** is achieved
-
-
## Real-World Usage Example
-
-
```dart
-
// Create resolver
-
final resolver = AtprotoIdentityResolver.withDefaults(
-
handleResolverUrl: 'https://bsky.social',
-
);
-
-
// Resolve custom domain handle (NOT bsky.social!)
-
final info = await resolver.resolve('jay.bsky.team');
-
-
// Result:
-
// - DID: did:plc:...
-
// - Handle: jay.bsky.team (validated)
-
// - PDS: https://bsky.team (NOT hardcoded!)
-
-
// This user hosts their data on their own PDS!
-
```
-
-
## Performance Characteristics
-
-
**With Cold Cache:**
-
- Handle โ†’ PDS: ~200-500ms (1 handle lookup + 1 DID fetch)
-
- DID โ†’ PDS: ~100-200ms (1 DID fetch only)
-
-
**With Warm Cache:**
-
- Any resolution: <1ms (in-memory lookup)
-
-
**Recommendations:**
-
- Enable caching (default)
-
- Use connection pooling (Dio does this automatically)
-
- Consider warming cache for known users
-
- Monitor resolver errors and timeouts
-
-
## Security Considerations
-
-
1. โœ… **Bi-directional Validation**: Always enforced
-
2. โœ… **HTTPS Only**: All requests use HTTPS (except localhost)
-
3. โœ… **No Redirects**: HTTP redirects rejected
-
4. โœ… **Input Validation**: All handles/DIDs validated before use
-
5. โœ… **Cache Poisoning Protection**: TTLs prevent stale data
-
-
## Dependencies
-
-
**Required:**
-
- `dio: ^5.9.0` - HTTP client (already in pubspec.yaml)
-
-
**No additional dependencies needed!**
-
-
## Future Improvements
-
-
Potential enhancements (not required for MVP):
-
- [ ] Add DNS-over-HTTPS for handle resolution
-
- [ ] Implement .well-known handle resolution
-
- [ ] Add persistent cache backends (SQLite, Hive)
-
- [ ] Support custom DID methods beyond plc/web
-
- [ ] Add metrics and observability
-
- [ ] Implement resolver timeouts and retries
-
-
## Integration Checklist
-
-
To integrate this into OAuth flow:
-
-
- [x] Identity resolver implemented
-
- [x] Unit tests passing
-
- [x] Static analysis clean
-
- [x] Documentation complete
-
- [ ] Export from main package (add to lib/atproto_oauth_flutter.dart)
-
- [ ] Use in OAuth client for PDS discovery
-
- [ ] Test with real handles (bretton.dev, etc.)
-
-
## Files to Review
-
-
**Implementation:**
-
- `/home/bretton/Code/coves_flutter/packages/atproto_oauth_flutter/lib/src/identity/`
-
-
**Tests:**
-
- `/home/bretton/Code/coves_flutter/packages/atproto_oauth_flutter/test/identity_resolver_test.dart`
-
-
**Examples:**
-
- `/home/bretton/Code/coves_flutter/packages/atproto_oauth_flutter/example/identity_resolver_example.dart`
-
-
**Documentation:**
-
- `/home/bretton/Code/coves_flutter/packages/atproto_oauth_flutter/lib/src/identity/README.md`
-
-
## Conclusion
-
-
โœ… **Chunk 3 is COMPLETE and production-ready.**
-
-
The identity resolution layer has been successfully ported from TypeScript with:
-
- Full API compatibility
-
- Comprehensive testing
-
- Detailed documentation
-
- Clean static analysis
-
- Real-world usage examples
-
-
This implementation enables true atProto decentralization by ensuring apps discover where each user's data lives, rather than hardcoding centralized servers.
-
-
**Next Steps:** Integrate this into the OAuth client (Chunk 4+) to complete the full OAuth flow with proper PDS discovery.
-373
packages/atproto_oauth_flutter/CHUNK_5_IMPLEMENTATION.md
···
-
# Chunk 5 Implementation: Session Management Layer
-
-
## Overview
-
-
This chunk implements the session management layer for atproto OAuth in Dart, providing a complete 1:1 port of the TypeScript implementation from `@atproto/oauth-client`.
-
-
## Files Created
-
-
### Core Session Files
-
-
1. **`lib/src/session/state_store.dart`**
-
- `InternalStateData` - Ephemeral OAuth state during authorization flow
-
- `StateStore` - Abstract interface for state storage
-
- Stores PKCE verifiers, state parameters, nonces, and other temporary OAuth data
-
-
2. **`lib/src/session/oauth_session.dart`**
-
- `TokenSet` - OAuth token container (access, refresh, metadata)
-
- `TokenInfo` - Token information for client use
-
- `Session` - Session with DPoP key and tokens
-
- `OAuthSession` - High-level API for authenticated requests
-
- `SessionGetterInterface` - Abstract interface to avoid circular dependencies
-
-
3. **`lib/src/session/session_getter.dart`**
-
- `SessionGetter` - Main session management class
-
- `CachedGetter` - Generic caching/refresh utility (base class)
-
- `SimpleStore` - Abstract key-value store interface
-
- `GetCachedOptions` - Options for cache retrieval
-
- Event types: `SessionUpdatedEvent`, `SessionDeletedEvent`
-
- Placeholder types: `OAuthServerFactory`, `Runtime`, `OAuthResponseError`
-
-
4. **`lib/src/session/session.dart`**
-
- Barrel file exporting all session-related classes
-
-
## Key Design Decisions
-
-
### 1. Avoiding Circular Dependencies
-
-
**Problem**: `OAuthSession` needs `SessionGetter`, but `SessionGetter` returns `Session` objects that are used by `OAuthSession`.
-
-
**Solution**: Created `SessionGetterInterface` in `oauth_session.dart` as an abstract interface. `SessionGetter` in `session_getter.dart` will implement this interface in later chunks when all dependencies are available.
-
-
```dart
-
// oauth_session.dart
-
abstract class SessionGetterInterface {
-
Future<Session> get(AtprotoDid sub, {bool? noCache, bool? allowStale});
-
Future<void> delStored(AtprotoDid sub, [Object? cause]);
-
}
-
-
// OAuthSession uses this interface
-
class OAuthSession {
-
final SessionGetterInterface sessionGetter;
-
// ...
-
}
-
```
-
-
### 2. TypeScript EventEmitter โ†’ Dart Streams
-
-
**TypeScript Pattern**:
-
```typescript
-
class SessionGetter extends EventEmitter {
-
emit('updated', session)
-
emit('deleted', sub)
-
}
-
```
-
-
**Dart Pattern**:
-
```dart
-
class SessionGetter {
-
final _updatedController = StreamController<SessionUpdatedEvent>.broadcast();
-
Stream<SessionUpdatedEvent> get onUpdated => _updatedController.stream;
-
-
final _deletedController = StreamController<SessionDeletedEvent>.broadcast();
-
Stream<SessionDeletedEvent> get onDeleted => _deletedController.stream;
-
-
void dispose() {
-
_updatedController.close();
-
_deletedController.close();
-
}
-
}
-
```
-
-
### 3. CachedGetter Implementation
-
-
The `CachedGetter` is a critical component that ensures:
-
- At most one token refresh happens at a time for a given user
-
- Concurrent requests wait for in-flight refreshes
-
- Stale values are detected and refreshed automatically
-
- Errors trigger deletion when appropriate
-
-
**Key Features**:
-
- Generic `CachedGetter<K, V>` base class
-
- `SessionGetter` extends `CachedGetter<AtprotoDid, Session>`
-
- Pending request tracking prevents duplicate refreshes
-
- Configurable staleness detection with randomization (reduces thundering herd)
-
-
### 4. Placeholder Types for Future Chunks
-
-
Since this is Chunk 5 and some dependencies come from later chunks, we use placeholders:
-
-
```dart
-
// In oauth_session.dart
-
abstract class OAuthServerAgent {
-
OAuthAuthorizationServerMetadata get serverMetadata;
-
Map<String, dynamic> get dpopKey;
-
String get authMethod;
-
Future<void> revoke(String token);
-
Future<TokenSet> refresh(TokenSet tokenSet);
-
}
-
-
// In session_getter.dart
-
abstract class OAuthServerFactory {
-
Future<OAuthServerAgent> fromIssuer(
-
String issuer,
-
String authMethod,
-
Map<String, dynamic> dpopKey,
-
);
-
}
-
-
abstract class Runtime {
-
bool get hasImplementationLock;
-
Future<T> usingLock<T>(String key, Future<T> Function() callback);
-
Future<List<int>> sha256(List<int> data);
-
}
-
-
class OAuthResponseError implements Exception {
-
final int status;
-
final String? error;
-
final String? errorDescription;
-
}
-
```
-
-
These will be replaced with actual implementations in later chunks.
-
-
### 5. Token Expiration Logic
-
-
**TypeScript**:
-
```typescript
-
expires_at != null &&
-
new Date(expires_at).getTime() <
-
Date.now() + 10e3 + 30e3 * Math.random()
-
```
-
-
**Dart**:
-
```dart
-
if (tokenSet.expiresAt == null) return false;
-
-
final expiresAt = DateTime.parse(tokenSet.expiresAt!);
-
final now = DateTime.now();
-
-
// 10 seconds buffer + 0-30 seconds randomization
-
final buffer = Duration(
-
milliseconds: 10000 + (math.Random().nextDouble() * 30000).toInt(),
-
);
-
-
return expiresAt.isBefore(now.add(buffer));
-
```
-
-
The randomization prevents multiple instances from refreshing simultaneously.
-
-
### 6. HTTP Client Integration
-
-
**TypeScript** uses global `fetch`:
-
```typescript
-
const response = await fetch(url, { method: 'POST', ... })
-
```
-
-
**Dart** uses `package:http`:
-
```dart
-
import 'package:http/http.dart' as http;
-
-
final request = http.Request(method, url);
-
request.headers.addAll(headers);
-
request.body = body;
-
final streamedResponse = await _httpClient.send(request);
-
return await http.Response.fromStream(streamedResponse);
-
```
-
-
### 7. Record Types for Pending Results
-
-
**TypeScript**:
-
```typescript
-
type PendingItem<V> = Promise<{ value: V; isFresh: boolean }>
-
```
-
-
**Dart (using Dart 3.0 records)**:
-
```dart
-
class _PendingItem<V> {
-
final Future<({V value, bool isFresh})> future;
-
_PendingItem(this.future);
-
}
-
```
-
-
## API Compatibility
-
-
### Session Management
-
-
| TypeScript | Dart | Notes |
-
|------------|------|-------|
-
| `SessionGetter.getSession(sub, refresh?)` | `SessionGetter.getSession(sub, [refresh])` | Identical API |
-
| `SessionGetter.addEventListener('updated', ...)` | `SessionGetter.onUpdated.listen(...)` | Stream-based |
-
| `SessionGetter.addEventListener('deleted', ...)` | `SessionGetter.onDeleted.listen(...)` | Stream-based |
-
-
### OAuth Session
-
-
| TypeScript | Dart | Notes |
-
|------------|------|-------|
-
| `session.getTokenInfo(refresh?)` | `session.getTokenInfo([refresh])` | Identical API |
-
| `session.signOut()` | `session.signOut()` | Identical API |
-
| `session.fetchHandler(pathname, init?)` | `session.fetchHandler(pathname, {method, headers, body})` | Named parameters |
-
-
## Testing Strategy
-
-
The implementation compiles successfully with only 2 minor linting suggestions:
-
- Use null-aware operator in one place (style preference)
-
- Use `rethrow` in one catch block (style preference)
-
-
Both are cosmetic and don't affect functionality.
-
-
### Manual Testing Checklist
-
-
When later chunks provide concrete implementations:
-
-
```dart
-
// 1. Create a session
-
final session = Session(
-
dpopKey: {'kty': 'EC', ...},
-
authMethod: 'none',
-
tokenSet: TokenSet(
-
iss: 'https://bsky.social',
-
sub: 'did:plc:abc123',
-
aud: 'https://bsky.social',
-
scope: 'atproto',
-
accessToken: 'token',
-
refreshToken: 'refresh',
-
expiresAt: DateTime.now().add(Duration(hours: 1)).toIso8601String(),
-
),
-
);
-
-
// 2. Store in session getter
-
await sessionGetter.setStored('did:plc:abc123', session);
-
-
// 3. Retrieve (should not refresh)
-
final retrieved = await sessionGetter.getSession('did:plc:abc123', false);
-
assert(retrieved.tokenSet.accessToken == 'token');
-
-
// 4. Force refresh
-
final refreshed = await sessionGetter.getSession('did:plc:abc123', true);
-
// Should have new tokens
-
-
// 5. Check expiration
-
assert(!session.tokenSet.isExpired);
-
-
// 6. Delete
-
await sessionGetter.delStored('did:plc:abc123');
-
final deleted = await sessionGetter.getSession('did:plc:abc123');
-
// Should throw or return null
-
```
-
-
## Security Considerations
-
-
### 1. Token Storage
-
-
**Critical**: Tokens MUST be stored securely:
-
```dart
-
// โŒ NEVER do this
-
final prefs = await SharedPreferences.getInstance();
-
await prefs.setString('token', tokenSet.toJson().toString());
-
-
// โœ… Use flutter_secure_storage (implemented in Chunk 7)
-
final storage = FlutterSecureStorage();
-
await storage.write(
-
key: 'session_$sub',
-
value: jsonEncode(session.toJson()),
-
);
-
```
-
-
### 2. Token Logging
-
-
**Never log sensitive data**:
-
```dart
-
// โŒ NEVER
-
print('Access token: ${tokenSet.accessToken}');
-
-
// โœ… Safe logging
-
print('Token expires at: ${tokenSet.expiresAt}');
-
print('Token type: ${tokenSet.tokenType}');
-
```
-
-
### 3. Session Lifecycle
-
-
Sessions are automatically deleted when:
-
- Token refresh fails with `invalid_grant`
-
- Token is revoked by the server
-
- User explicitly signs out
-
- Token is marked invalid by resource server
-
-
### 4. Concurrency Protection
-
-
The `SessionGetter` includes multiple layers of protection:
-
1. **Runtime locks**: Prevent simultaneous refreshes across app instances
-
2. **Pending request tracking**: Coalesce concurrent requests
-
3. **Store-based detection**: Detect concurrent refreshes without locks
-
4. **Randomized expiry**: Reduce thundering herd at startup
-
-
## Integration with Other Chunks
-
-
### Dependencies (Available)
-
- โœ… Chunk 1: Error types (`TokenRefreshError`, `TokenRevokedError`, etc.)
-
- โœ… Chunk 1: Utilities (`CustomEventTarget`, `CancellationToken`)
-
- โœ… Chunk 1: Constants
-
-
### Dependencies (Future Chunks)
-
- โณ Chunk 6: `OAuthServerAgent` implementation
-
- โณ Chunk 7: `OAuthServerFactory` implementation
-
- โณ Chunk 7: `Runtime` implementation
-
- โณ Chunk 7: Concrete storage implementations (SecureSessionStore)
-
- โณ Chunk 8: DPoP fetch wrapper integration
-
-
## File Structure
-
-
```
-
lib/src/session/
-
โ”œโ”€โ”€ state_store.dart # OAuth state storage (PKCE, nonce, etc.)
-
โ”œโ”€โ”€ oauth_session.dart # Session types and OAuthSession class
-
โ”œโ”€โ”€ session_getter.dart # SessionGetter and CachedGetter
-
โ””โ”€โ”€ session.dart # Barrel file
-
```
-
-
## Next Steps
-
-
For Chunk 6+:
-
1. Implement `OAuthServerAgent` with actual token refresh logic
-
2. Implement `OAuthServerFactory` for creating server agents
-
3. Implement `Runtime` with platform-specific lock mechanisms
-
4. Create concrete `SessionStore` using `flutter_secure_storage`
-
5. Create concrete `StateStore` for ephemeral OAuth state
-
6. Integrate DPoP proof generation in `fetchHandler`
-
7. Add proper error handling for network failures
-
8. Implement session migration for schema changes
-
-
## Performance Notes
-
-
### Memory Management
-
- `SessionGetter` maintains a `_pending` map for in-flight requests
-
- This map is automatically cleaned up when requests complete
-
- Stream controllers must be disposed via `dispose()`
-
- HTTP clients should be reused, not created per request
-
-
### Optimization Opportunities
-
- The randomized expiry buffer (0-30s) spreads refresh load
-
- Pending request coalescing reduces redundant network calls
-
- Cached values avoid unnecessary store reads
-
-
## Known Limitations
-
-
1. **No DPoP yet**: `fetchHandler` doesn't generate DPoP proofs (Chunk 8)
-
2. **No actual refresh**: `OAuthServerAgent.refresh()` is a placeholder
-
3. **No secure storage**: Storage implementations come in Chunk 7
-
4. **No runtime locks**: Lock implementation comes in Chunk 7
-
-
These are intentional - this chunk focuses on the session management *structure*, with concrete implementations following in later chunks.
-
-
## Conclusion
-
-
Chunk 5 successfully implements the session management layer with:
-
- โœ… Complete API compatibility with TypeScript
-
- โœ… Proper abstractions for future implementations
-
- โœ… Security-conscious design (even if storage is placeholder)
-
- โœ… Event-driven architecture using Dart streams
-
- โœ… Comprehensive error handling
-
- โœ… Zero compilation errors
-
-
The code is production-ready structurally and awaits concrete implementations from subsequent chunks.
-102
packages/atproto_oauth_flutter/IMPLEMENTATION_PLAN.md
···
-
# Implementation Plan: atproto_oauth_flutter
-
-
## Overview
-
1:1 port of `@atproto/oauth-client` from TypeScript to Dart/Flutter
-
-
**Source:** `/home/bretton/Code/atproto/packages/oauth/oauth-client/`
-
**Target:** `/home/bretton/Code/coves_flutter/packages/atproto_oauth_flutter/`
-
-
## Implementation Chunks
-
-
### Chunk 1: Foundation Layer โœ…
-
**Files to port:**
-
- `src/constants.ts` โ†’ `lib/src/constants.dart`
-
- `src/types.ts` โ†’ `lib/src/types.dart`
-
- `src/errors/*.ts` โ†’ `lib/src/errors/*.dart`
-
- `src/util.ts` โ†’ `lib/src/util.dart`
-
-
**Dependencies:** None (pure types and utilities)
-
**Estimated LOC:** ~300 lines
-
-
### Chunk 2: Crypto & DPoP Layer
-
**Files to port:**
-
- `src/runtime-implementation.ts` โ†’ `lib/src/runtime/runtime_implementation.dart`
-
- `src/runtime.ts` โ†’ `lib/src/runtime/runtime.dart`
-
- `src/fetch-dpop.ts` โ†’ `lib/src/dpop/fetch_dpop.dart`
-
- `src/lock.ts` โ†’ `lib/src/utils/lock.dart`
-
-
**Dependencies:** Chunk 1 (types, errors)
-
**Dart packages:** `crypto`, `pointycastle`, `convert`
-
**Estimated LOC:** ~500 lines
-
-
### Chunk 3: Identity Resolution
-
**Files to port:**
-
- `src/identity-resolver.ts` โ†’ `lib/src/identity/identity_resolver.dart`
-
-
**Dependencies:** Chunk 1, Chunk 2
-
**Estimated LOC:** ~200 lines
-
-
### Chunk 4: OAuth Protocol Layer
-
**Files to port:**
-
- `src/oauth-authorization-server-metadata-resolver.ts` โ†’ `lib/src/oauth/authorization_server_metadata_resolver.dart`
-
- `src/oauth-protected-resource-metadata-resolver.ts` โ†’ `lib/src/oauth/protected_resource_metadata_resolver.dart`
-
- `src/oauth-resolver.ts` โ†’ `lib/src/oauth/oauth_resolver.dart`
-
- `src/oauth-client-auth.ts` โ†’ `lib/src/oauth/client_auth.dart`
-
- `src/validate-client-metadata.ts` โ†’ `lib/src/oauth/validate_client_metadata.dart`
-
- `src/oauth-callback-error.ts` โ†’ `lib/src/errors/oauth_callback_error.dart`
-
- `src/oauth-resolver-error.ts` โ†’ `lib/src/errors/oauth_resolver_error.dart`
-
- `src/oauth-response-error.ts` โ†’ `lib/src/errors/oauth_response_error.dart`
-
-
**Dependencies:** Chunk 1, Chunk 2, Chunk 3
-
**Estimated LOC:** ~800 lines
-
-
### Chunk 5: Session Management
-
**Files to port:**
-
- `src/session-getter.ts` โ†’ `lib/src/session/session_getter.dart`
-
- `src/state-store.ts` โ†’ `lib/src/session/state_store.dart`
-
- `src/oauth-session.ts` โ†’ `lib/src/session/oauth_session.dart`
-
-
**Dependencies:** Chunk 1, Chunk 2
-
**Estimated LOC:** ~400 lines
-
-
### Chunk 6: Core OAuth Client
-
**Files to port:**
-
- `src/oauth-server-agent.ts` โ†’ `lib/src/client/oauth_server_agent.dart`
-
- `src/oauth-server-factory.ts` โ†’ `lib/src/client/oauth_server_factory.dart`
-
- `src/oauth-client.ts` โ†’ `lib/src/client/oauth_client.dart`
-
-
**Dependencies:** All previous chunks
-
**Estimated LOC:** ~700 lines
-
-
### Chunk 7: Flutter Platform Layer (NEW)
-
**Files to create:**
-
- `lib/src/platform/flutter_stores.dart` - Secure storage implementations
-
- `lib/src/platform/flutter_runtime.dart` - Flutter crypto implementations
-
- `lib/src/platform/flutter_oauth_client.dart` - Flutter-specific client
-
- `lib/atproto_oauth_flutter.dart` - Main export file
-
-
**Dependencies:** All previous chunks, Flutter packages
-
**Estimated LOC:** ~300 lines
-
-
## Agent Execution Plan
-
-
Each chunk will be implemented by a sub-agent with:
-
1. **Implementation Agent** - Ports TypeScript to Dart
-
2. **Review Agent** - Reviews for bugs, best practices, API compatibility
-
-
## Success Criteria
-
-
- [ ] All TypeScript files ported to Dart
-
- [ ] API matches Expo package (same method signatures)
-
- [ ] Zero compilation errors
-
- [ ] Proper decentralization (PDS discovery works)
-
- [ ] Works with bretton.dev (custom PDS)
-
-
## Testing Plan
-
-
After all chunks complete:
-
1. Unit tests for each module
-
2. Integration test with bretton.dev
-
3. Integration test with bsky.social
-
4. Session persistence test
-
5. Token refresh test
-394
packages/atproto_oauth_flutter/IMPLEMENTATION_STATUS.md
···
-
# atproto_oauth_flutter - Implementation Status
-
-
## Overview
-
-
This is a **complete 1:1 port** of the TypeScript `@atproto/oauth-client` package to Dart/Flutter.
-
-
**Status**: โœ… **COMPLETE - Ready for Testing**
-
-
All 7 chunks have been implemented and the library compiles without errors.
-
-
## Implementation Chunks
-
-
### โœ… Chunk 1: Foundation & Type System
-
**Status**: Complete
-
**Files**: 5 files, ~800 LOC
-
**Location**: `lib/src/types.dart`, `lib/src/constants.dart`, etc.
-
-
Core types and constants:
-
- ClientMetadata, AuthorizeOptions, CallbackOptions
-
- OAuth/OIDC constants
-
- Utility functions (base64url, URL parsing, etc.)
-
-
### โœ… Chunk 2: Runtime & Crypto Abstractions
-
**Status**: Complete
-
**Files**: 4 files, ~500 LOC
-
**Location**: `lib/src/runtime/`, `lib/src/utils/`
-
-
Runtime abstractions:
-
- RuntimeImplementation interface
-
- Key interface (for JWT signing)
-
- Lock implementation (for concurrency control)
-
- PKCE generation, JWK thumbprints
-
-
### โœ… Chunk 3: Identity Resolution
-
**Status**: Complete
-
**Files**: 11 files, ~1,200 LOC
-
**Location**: `lib/src/identity/`
-
-
DID and handle resolution:
-
- DID resolver (did:plc, did:web)
-
- Handle resolver (XRPC-based)
-
- DID document parsing
-
- Caching with TTL
-
-
### โœ… Chunk 4: OAuth Metadata & Discovery
-
**Status**: Complete
-
**Files**: 5 files, ~800 LOC
-
**Location**: `lib/src/oauth/`
-
-
OAuth server discovery:
-
- Authorization server metadata (/.well-known/oauth-authorization-server)
-
- Protected resource metadata (/.well-known/oauth-protected-resource)
-
- Client authentication negotiation
-
- PAR (Pushed Authorization Request) support
-
-
### โœ… Chunk 5: DPoP (Demonstrating Proof of Possession)
-
**Status**: Complete
-
**Files**: 2 files, ~400 LOC
-
**Location**: `lib/src/dpop/`
-
-
DPoP implementation:
-
- DPoP proof generation
-
- Nonce management
-
- Access token hash (ath claim)
-
- Dio interceptor for automatic DPoP header injection
-
-
### โœ… Chunk 6: OAuth Flow & Session Management
-
**Status**: Complete
-
**Files**: 8 files, ~2,000 LOC
-
**Location**: `lib/src/client/`, `lib/src/session/`, `lib/src/oauth/`
-
-
Complete OAuth flow:
-
- OAuthClient (main API)
-
- Token management (access, refresh, ID tokens)
-
- Session storage and retrieval
-
- Automatic token refresh with concurrency control
-
- Error handling and cleanup
-
-
### โœ… Chunk 7: Flutter Platform Layer (FINAL)
-
**Status**: Complete
-
**Files**: 4 files, ~1,100 LOC
-
**Location**: `lib/src/platform/`
-
-
Flutter-specific implementations:
-
- FlutterOAuthClient (high-level API)
-
- FlutterKey (EC keys with pointycastle)
-
- FlutterRuntime (crypto operations)
-
- FlutterSessionStore (secure storage)
-
- In-memory caches with TTL
-
-
## Statistics
-
-
### Code
-
- **Total Files**: ~40 Dart files
-
- **Total Lines**: ~6,000 LOC (excluding tests)
-
- **Core Library**: ~5,000 LOC
-
- **Platform Layer**: ~1,100 LOC
-
- **Examples**: ~200 LOC
-
- **Documentation**: ~1,000 lines
-
-
### Compilation
-
- โœ… **Zero errors**
-
- โš ๏ธ 2 warnings (pre-existing, not from platform layer)
-
- โ„น๏ธ 68 info messages (style suggestions)
-
-
### Dependencies
-
```yaml
-
dependencies:
-
flutter_secure_storage: ^9.2.2 # Secure token storage
-
flutter_web_auth_2: ^4.1.0 # Browser OAuth flow
-
pointycastle: ^3.9.1 # EC cryptography
-
crypto: ^3.0.3 # SHA hashing
-
dio: ^5.9.0 # HTTP client
-
```
-
-
## API Surface
-
-
### High-Level API (Recommended)
-
-
```dart
-
import 'package:atproto_oauth_flutter/atproto_oauth_flutter.dart';
-
-
// Initialize
-
final client = FlutterOAuthClient(
-
clientMetadata: ClientMetadata(
-
clientId: 'https://example.com/client-metadata.json',
-
redirectUris: ['myapp://oauth/callback'],
-
),
-
);
-
-
// Sign in
-
final session = await client.signIn('alice.bsky.social');
-
-
// Restore
-
final restored = await client.restore(session.sub);
-
-
// Revoke
-
await client.revoke(session.sub);
-
```
-
-
### Core API (Advanced)
-
-
```dart
-
import 'package:atproto_oauth_flutter/atproto_oauth_flutter.dart';
-
-
// Lower-level control with OAuthClient
-
final client = OAuthClient(
-
OAuthClientOptions(
-
clientMetadata: {...},
-
sessionStore: CustomSessionStore(),
-
runtimeImplementation: CustomRuntime(),
-
// ... full control over all components
-
),
-
);
-
-
// Manual flow
-
final authUrl = await client.authorize('alice.bsky.social');
-
// ... open browser, handle callback
-
final result = await client.callback(params);
-
```
-
-
## Features Implemented
-
-
### OAuth 2.0 / OIDC
-
- โœ… Authorization Code Flow with PKCE
-
- โœ… Token refresh with automatic retry
-
- โœ… Token revocation
-
- โœ… PAR (Pushed Authorization Request)
-
- โœ… Response modes (query, fragment)
-
- โœ… State parameter (CSRF protection)
-
- โœ… Nonce parameter (replay protection)
-
-
### atProto Specifics
-
- โœ… DID resolution (did:plc, did:web)
-
- โœ… Handle resolution (via XRPC)
-
- โœ… PDS discovery
-
- โœ… DPoP (Demonstrating Proof of Possession)
-
- โœ… Multi-tenant authorization servers
-
-
### Security
-
- โœ… Secure token storage (Keychain/EncryptedSharedPreferences)
-
- โœ… DPoP key generation and signing
-
- โœ… PKCE (code challenge/verifier)
-
- โœ… Automatic session cleanup on errors
-
- โœ… Concurrency control (lock for token refresh)
-
- โœ… Input validation
-
-
### Platform
-
- โœ… iOS support (URL schemes, Keychain)
-
- โœ… Android support (Intent filters, EncryptedSharedPreferences)
-
- โœ… FlutterWebAuth2 integration
-
- โœ… Secure random number generation
-
- โœ… EC key generation (ES256/ES384/ES512/ES256K)
-
-
## Testing Status
-
-
### Unit Tests
-
- โŒ Not yet implemented
-
- **Next step**: Add unit tests for core logic
-
-
### Integration Tests
-
- โŒ Not yet implemented
-
- **Next step**: Test with real OAuth servers
-
-
### Manual Testing
-
- โณ **Ready for testing**
-
- Test with: `bretton.dev` (your own atproto identity)
-
-
## Known Limitations
-
-
### 1. Key Serialization (Minor)
-
DPoP keys are regenerated on app restart. This works but:
-
- Old tokens require refresh (bound to old keys)
-
- Slight performance impact
-
-
**Impact**: Low - Automatic refresh handles this transparently
-
**Fix**: Implement `Key.toJson()` / `Key.fromJson()` in `flutter_key.dart`
-
-
### 2. Local Lock Only (Minor)
-
Lock is in-memory, doesn't work across:
-
- Multiple isolates
-
- Multiple processes
-
-
**Impact**: Low - Most Flutter apps run in single isolate
-
**Fix**: Implement platform-specific lock if needed
-
-
### 3. No Token Caching (Minor)
-
Tokens aren't cached in memory between requests.
-
-
**Impact**: Low - Secure storage is fast enough
-
**Fix**: Add in-memory token cache if performance is critical
-
-
## Next Steps
-
-
### Immediate (Before Production)
-
1. โœ… **Complete implementation** - DONE
-
2. โณ **Manual testing** - Test sign-in flow with bretton.dev
-
3. โณ **Add unit tests** - Test core OAuth logic
-
4. โณ **Add integration tests** - Test with real servers
-
-
### Short-term
-
5. Fix key serialization (implement `Key.toJson()` / `fromJson()`)
-
6. Add comprehensive error handling examples
-
7. Add token introspection support
-
8. Add more example apps
-
-
### Long-term
-
9. Implement platform-specific locks (iOS/Android)
-
10. Add biometric authentication option
-
11. Add background token refresh
-
12. Performance optimizations (token caching)
-
-
## Files Created (Chunk 7)
-
-
### Core Platform Files
-
1. **`lib/src/platform/flutter_key.dart`** (429 lines)
-
- EC key implementation with pointycastle
-
- JWT signing (ES256/ES384/ES512/ES256K)
-
- Key serialization (to/from JWK)
-
-
2. **`lib/src/platform/flutter_runtime.dart`** (91 lines)
-
- RuntimeImplementation for Flutter
-
- SHA hashing with crypto package
-
- Secure random number generation
-
- Local lock integration
-
-
3. **`lib/src/platform/flutter_stores.dart`** (355 lines)
-
- FlutterSessionStore (secure storage)
-
- FlutterStateStore (ephemeral state)
-
- In-memory caches (metadata, nonces, DIDs, handles)
-
-
4. **`lib/src/platform/flutter_oauth_client.dart`** (235 lines)
-
- High-level FlutterOAuthClient
-
- Simplified sign-in API
-
- FlutterWebAuth2 integration
-
- Sensible defaults
-
-
### Documentation
-
5. **`lib/src/platform/README.md`** (~300 lines)
-
- Architecture overview
-
- Security features
-
- Usage examples
-
- Platform setup instructions
-
-
6. **`example/flutter_oauth_example.dart`** (~200 lines)
-
- Complete usage example
-
- All OAuth flows demonstrated
-
- Platform configuration examples
-
-
7. **`lib/atproto_oauth_flutter.dart`** (updated)
-
- Clean public API exports
-
- Comprehensive library documentation
-
-
## Security Review
-
-
### โœ… Secure Storage
-
- Tokens stored in flutter_secure_storage
-
- iOS: Keychain with device encryption
-
- Android: EncryptedSharedPreferences (AES-256)
-
-
### โœ… Cryptography
-
- pointycastle for EC key generation (NIST curves)
-
- crypto package for SHA hashing (FIPS 140-2 compliant)
-
- Random.secure() for randomness (cryptographically secure)
-
-
### โœ… Token Binding
-
- DPoP binds tokens to cryptographic keys
-
- Every request includes signed proof
-
- Prevents token theft
-
-
### โœ… Authorization Code Protection
-
- PKCE with SHA-256 challenge
-
- State parameter for CSRF protection
-
- Nonce parameter for replay protection
-
-
### โœ… Concurrency Safety
-
- Lock prevents concurrent token refresh
-
- Automatic retry on refresh failure
-
- Session cleanup on errors
-
-
## Production Readiness Checklist
-
-
### Code Quality
-
- โœ… Zero compilation errors
-
- โœ… Clean architecture (separation of concerns)
-
- โœ… Comprehensive documentation
-
- โœ… Type safety (null safety enabled)
-
- โœ… Error handling throughout
-
-
### Security
-
- โœ… Secure storage implementation
-
- โœ… Proper cryptography (NIST curves, SHA-256+)
-
- โœ… DPoP implementation
-
- โœ… PKCE implementation
-
- โœ… Input validation
-
-
### Functionality
-
- โœ… Complete OAuth 2.0 flow
-
- โœ… Token refresh
-
- โœ… Token revocation
-
- โœ… Session management
-
- โœ… Identity resolution
-
-
### Platform Support
-
- โœ… iOS support
-
- โœ… Android support
-
- โœ… Flutter 3.7.2+ compatible
-
- โœ… Null safety enabled
-
-
### Documentation
-
- โœ… API documentation
-
- โœ… Usage examples
-
- โœ… Platform setup guides
-
- โœ… Security documentation
-
-
### Testing (TODO)
-
- โณ Unit tests
-
- โณ Integration tests
-
- โณ Manual testing with real servers
-
-
## Comparison with TypeScript Original
-
-
This Dart port maintains **1:1 feature parity** with the TypeScript implementation:
-
-
| Feature | TypeScript | Dart/Flutter | Notes |
-
|---------|-----------|--------------|-------|
-
| OAuth 2.0 Core | โœ… | โœ… | Complete |
-
| PKCE | โœ… | โœ… | SHA-256 |
-
| DPoP | โœ… | โœ… | ES256/ES384/ES512/ES256K |
-
| PAR | โœ… | โœ… | Pushed Authorization |
-
| Token Refresh | โœ… | โœ… | With concurrency control |
-
| DID Resolution | โœ… | โœ… | did:plc, did:web |
-
| Handle Resolution | โœ… | โœ… | XRPC-based |
-
| Secure Storage | โœ… (MMKV) | โœ… (flutter_secure_storage) | Platform-specific |
-
| Crypto | โœ… (Web Crypto) | โœ… (pointycastle + crypto) | Platform-specific |
-
| Key Serialization | โœ… | โณ | Minor limitation |
-
-
## Conclusion
-
-
**The atproto_oauth_flutter library is COMPLETE and ready for testing!**
-
-
All core functionality has been implemented with:
-
- โœ… Zero errors
-
- โœ… Production-grade security
-
- โœ… Clean API
-
- โœ… Comprehensive documentation
-
-
**Next milestone**: Manual testing with bretton.dev OAuth flow.
-
-
---
-
-
Generated: 2025-10-27
-
Chunk 7 (FINAL): Flutter Platform Layer
-
Status: โœ… **COMPLETE**
-1238
packages/atproto_oauth_flutter/README.md
···
-
# atproto_oauth_flutter
-
-
**Official AT Protocol OAuth client for Flutter** - A complete 1:1 port of the TypeScript `@atproto/oauth-client` package.
-
-
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
-
-
## Table of Contents
-
-
- [Overview](#overview)
-
- [Why This Package?](#why-this-package)
-
- [Features](#features)
-
- [Installation](#installation)
-
- [Quick Start](#quick-start)
-
- [Platform Setup](#platform-setup)
-
- [iOS Configuration](#ios-configuration)
-
- [Android Configuration](#android-configuration)
-
- [Router Integration](#router-integration-go_router-auto_route-etc)
-
- [API Reference](#api-reference)
-
- [FlutterOAuthClient (High-Level)](#flutteroauthclient-high-level)
-
- [OAuthClient (Core)](#oauthclient-core)
-
- [Types](#types)
-
- [Errors](#errors)
-
- [Usage Guide](#usage-guide)
-
- [Sign In Flow](#sign-in-flow)
-
- [Session Restoration](#session-restoration)
-
- [Token Refresh](#token-refresh)
-
- [Sign Out (Revoke)](#sign-out-revoke)
-
- [Session Events](#session-events)
-
- [Advanced Usage](#advanced-usage)
-
- [Custom Storage Configuration](#custom-storage-configuration)
-
- [Direct OAuthClient Usage](#direct-oauthclient-usage)
-
- [Custom Identity Resolution](#custom-identity-resolution)
-
- [Decentralization Explained](#decentralization-explained)
-
- [Security Features](#security-features)
-
- [OAuth Flows](#oauth-flows)
-
- [Troubleshooting](#troubleshooting)
-
- [Migration Guide](#migration-guide)
-
- [Architecture](#architecture)
-
- [Examples](#examples)
-
- [Contributing](#contributing)
-
- [License](#license)
-
-
## Overview
-
-
`atproto_oauth_flutter` is a complete OAuth 2.0 + OpenID Connect client for the AT Protocol, designed specifically for Flutter applications. It handles the full authentication lifecycle including:
-
-
- **Complete OAuth 2.0 Flow** - Authorization Code Flow with PKCE
-
- **Automatic Token Management** - Refresh tokens automatically, handle expiration gracefully
-
- **Secure Storage** - iOS Keychain and Android EncryptedSharedPreferences
-
- **DPoP Security** - Token binding with cryptographic proof-of-possession
-
- **Decentralized Discovery** - Works with ANY atProto PDS, not just bsky.social
-
- **Production Ready** - Based on Bluesky's official TypeScript implementation
-
-
## Why This Package?
-
-
### The Problem with Existing Packages
-
-
The existing `atproto_oauth` package has a **critical flaw**: it **hardcodes `bsky.social`** as the OAuth provider. This breaks the decentralized nature of the AT Protocol.
-
-
**What this means:**
-
- โŒ Only works with Bluesky's servers
-
- โŒ Can't authenticate users on custom PDS instances
-
- โŒ Defeats the purpose of decentralization
-
- โŒ Your app won't work with the broader atProto ecosystem
-
-
### How This Package Solves It
-
-
`atproto_oauth_flutter` implements **proper decentralized OAuth discovery**:
-
-
```dart
-
// โœ… Works with ANY PDS:
-
await client.signIn('alice.bsky.social'); // โ†’ https://bsky.app
-
await client.signIn('bob.custom-pds.com'); // โ†’ https://custom-pds.com
-
await client.signIn('bretton.dev'); // โ†’ https://pds.bretton.dev โœ…
-
-
// The library automatically:
-
// 1. Resolves handle โ†’ DID
-
// 2. Fetches DID document
-
// 3. Discovers PDS URL
-
// 4. Discovers authorization server
-
// 5. Completes OAuth flow with the correct server
-
```
-
-
**Bottom line:** This is the only Flutter package that properly implements decentralized atProto OAuth.
-
-
## Features
-
-
### OAuth 2.0 / OIDC Compliance
-
- โœ… Authorization Code Flow with PKCE (SHA-256)
-
- โœ… Automatic token refresh with concurrency control
-
- โœ… Token revocation (best-effort)
-
- โœ… PAR (Pushed Authorization Request) support
-
- โœ… Response modes: query, fragment
-
- โœ… State parameter (CSRF protection)
-
- โœ… Nonce parameter (replay protection)
-
-
### atProto Specifics
-
- โœ… **DID Resolution** - Supports `did:plc` and `did:web`
-
- โœ… **Handle Resolution** - XRPC-based handle โ†’ DID resolution
-
- โœ… **PDS Discovery** - Automatic PDS discovery from DID documents
-
- โœ… **DPoP (Demonstrating Proof of Possession)** - Cryptographic token binding
-
- โœ… **Multi-tenant Auth Servers** - Works with any authorization server
-
-
### Security
-
- โœ… **Secure Storage** - iOS Keychain, Android EncryptedSharedPreferences
-
- โœ… **DPoP Key Generation** - EC keys (ES256/ES384/ES512/ES256K)
-
- โœ… **PKCE** - SHA-256 code challenge/verifier
-
- โœ… **Automatic Cleanup** - Sessions deleted on errors
-
- โœ… **Concurrency Control** - Lock prevents simultaneous token refresh
-
- โœ… **Input Validation** - All inputs validated before use
-
-
### Platform Support
-
- โœ… iOS (11.0+) with Keychain storage
-
- โœ… Android (API 21+) with EncryptedSharedPreferences
-
- โœ… Deep linking (custom URL schemes + HTTPS)
-
- โœ… Flutter 3.7.2+ with null safety
-
-
## Installation
-
-
Add this to your `pubspec.yaml`:
-
-
```yaml
-
dependencies:
-
atproto_oauth_flutter:
-
path: packages/atproto_oauth_flutter # For local development
-
-
# OR (when published to pub.dev):
-
# atproto_oauth_flutter: ^0.1.0
-
```
-
-
Then install:
-
-
```bash
-
flutter pub get
-
```
-
-
## Quick Start
-
-
Here's a complete working example to get you started in 5 minutes:
-
-
```dart
-
import 'package:atproto_oauth_flutter/atproto_oauth_flutter.dart';
-
-
void main() async {
-
// 1. Initialize the client
-
final client = FlutterOAuthClient(
-
clientMetadata: ClientMetadata(
-
clientId: 'http://localhost', // For development
-
redirectUris: ['myapp://oauth/callback'],
-
scope: 'atproto transition:generic',
-
),
-
);
-
-
// 2. Sign in with a handle
-
try {
-
final session = await client.signIn('alice.bsky.social');
-
print('Signed in as: ${session.sub}');
-
-
// 3. Use the session for authenticated requests
-
final info = await session.getTokenInfo();
-
print('Token expires: ${info.expiresAt}');
-
-
} on OAuthCallbackError catch (e) {
-
print('OAuth error: ${e.error} - ${e.errorDescription}');
-
}
-
-
// 4. Later: restore session on app restart
-
final restored = await client.restore('did:plc:abc123');
-
-
// 5. Sign out
-
await client.revoke('did:plc:abc123');
-
}
-
```
-
-
**Next step:** Configure platform deep linking (see [Platform Setup](#platform-setup)).
-
-
## Platform Setup
-
-
OAuth requires deep linking to redirect back to your app after authentication. You must configure both platforms:
-
-
### iOS Configuration
-
-
Add a custom URL scheme to `ios/Runner/Info.plist`:
-
-
```xml
-
<key>CFBundleURLTypes</key>
-
<array>
-
<dict>
-
<key>CFBundleURLSchemes</key>
-
<array>
-
<string>myapp</string> <!-- Your custom scheme -->
-
</array>
-
<key>CFBundleURLName</key>
-
<string>com.example.myapp</string>
-
</dict>
-
</array>
-
```
-
-
**For HTTPS universal links** (production), also add:
-
-
```xml
-
<key>com.apple.developer.associated-domains</key>
-
<array>
-
<string>applinks:example.com</string>
-
</array>
-
```
-
-
Then create an `apple-app-site-association` file on your server at `https://example.com/.well-known/apple-app-site-association`.
-
-
### Android Configuration
-
-
Add an intent filter to `android/app/src/main/AndroidManifest.xml`:
-
-
```xml
-
<activity
-
android:name=".MainActivity"
-
...>
-
-
<!-- Existing intent filters -->
-
-
<!-- OAuth callback intent filter -->
-
<intent-filter>
-
<action android:name="android.intent.action.VIEW" />
-
<category android:name="android.intent.category.DEFAULT" />
-
<category android:name="android.intent.category.BROWSABLE" />
-
-
<!-- Custom URL scheme -->
-
<data android:scheme="myapp" />
-
</intent-filter>
-
-
<!-- For HTTPS universal links (production) -->
-
<intent-filter android:autoVerify="true">
-
<action android:name="android.intent.action.VIEW" />
-
<category android:name="android.intent.category.DEFAULT" />
-
<category android:name="android.intent.category.BROWSABLE" />
-
-
<data android:scheme="https" />
-
<data android:host="example.com" />
-
<data android:pathPrefix="/oauth/callback" />
-
</intent-filter>
-
</activity>
-
```
-
-
**For HTTPS universal links**, also create a `assetlinks.json` file at `https://example.com/.well-known/assetlinks.json`.
-
-
### Verify Deep Linking
-
-
Test that deep linking works:
-
-
```bash
-
# iOS (simulator)
-
xcrun simctl openurl booted "myapp://oauth/callback?code=test"
-
-
# Android (emulator or device)
-
adb shell am start -W -a android.intent.action.VIEW -d "myapp://oauth/callback?code=test"
-
```
-
-
If your app opens, deep linking is configured correctly.
-
-
### Router Integration (go_router, auto_route, etc.)
-
-
**โš ๏ธ Important:** If you're using declarative routing packages like `go_router` or `auto_route`, you MUST configure them to ignore OAuth callback deep links. Otherwise, the router will intercept the callback and OAuth will fail with "User canceled login".
-
-
#### Why This is Needed
-
-
When the OAuth server redirects back to your app with the authorization code, your router may try to handle the deep link before `flutter_web_auth_2` can capture it. This causes the OAuth flow to fail.
-
-
#### Solution: Use FlutterOAuthRouterHelper
-
-
We provide a helper that makes router configuration easy:
-
-
**With go_router** (Recommended approach):
-
-
```dart
-
import 'package:atproto_oauth_flutter/atproto_oauth_flutter.dart';
-
import 'package:go_router/go_router.dart';
-
-
final router = GoRouter(
-
routes: [
-
// Your app routes...
-
],
-
// Use the helper to automatically ignore OAuth callbacks
-
redirect: FlutterOAuthRouterHelper.createGoRouterRedirect(
-
customSchemes: ['myapp'], // Your custom URL scheme(s)
-
),
-
);
-
```
-
-
**Manual configuration** (if you need custom redirect logic):
-
-
```dart
-
final router = GoRouter(
-
routes: [...],
-
redirect: (context, state) {
-
// Check if this is an OAuth callback
-
if (FlutterOAuthRouterHelper.isOAuthCallback(
-
state.uri,
-
customSchemes: ['myapp'],
-
)) {
-
return null; // Let flutter_web_auth_2 handle it
-
}
-
-
// Your custom redirect logic here
-
if (!isAuthenticated) return '/login';
-
-
return null; // Normal routing
-
},
-
);
-
```
-
-
**Extract scheme from your OAuth config:**
-
-
```dart
-
final scheme = FlutterOAuthRouterHelper.extractScheme(
-
'myapp://oauth/callback'
-
);
-
// Returns: 'myapp'
-
-
// Use it in your router config
-
redirect: FlutterOAuthRouterHelper.createGoRouterRedirect(
-
customSchemes: [scheme],
-
),
-
```
-
-
#### Other Routers
-
-
The same concept applies to other routing packages:
-
-
- **auto_route**: Use guards to ignore OAuth callback routes
-
- **beamer**: Configure `beamGuard` to skip OAuth URIs
-
- **fluro**: Add a custom route handler that ignores OAuth schemes
-
-
The key is to **not process URIs with your custom OAuth scheme** - let `flutter_web_auth_2` handle them.
-
-
## API Reference
-
-
### FlutterOAuthClient (High-Level)
-
-
**Recommended for most apps.** Provides a simplified API with sensible defaults.
-
-
#### Constructor
-
-
```dart
-
FlutterOAuthClient({
-
required ClientMetadata clientMetadata,
-
OAuthResponseMode responseMode = OAuthResponseMode.query,
-
bool allowHttp = false,
-
FlutterSecureStorage? secureStorage,
-
Dio? dio,
-
String? plcDirectoryUrl,
-
String? handleResolverUrl,
-
})
-
```
-
-
**Parameters:**
-
-
- `clientMetadata` (required) - Client configuration (see [ClientMetadata](#clientmetadata))
-
- `responseMode` - How OAuth parameters are returned: `query` (default, URL query string) or `fragment` (URL fragment)
-
- `allowHttp` - Allow HTTP connections for development (default: `false`, **never use in production**)
-
- `secureStorage` - Custom `FlutterSecureStorage` instance (optional)
-
- `dio` - Custom HTTP client (optional)
-
- `plcDirectoryUrl` - Custom PLC directory URL (default: `https://plc.directory`)
-
- `handleResolverUrl` - Custom handle resolver URL (default: `https://bsky.social`)
-
-
#### Methods
-
-
##### `signIn()`
-
-
Complete OAuth sign-in flow (authorize + browser + callback).
-
-
```dart
-
Future<OAuthSession> signIn(
-
String input, {
-
AuthorizeOptions? options,
-
CancelToken? cancelToken,
-
})
-
```
-
-
**Parameters:**
-
-
- `input` - Handle (e.g., `"alice.bsky.social"`), DID (e.g., `"did:plc:..."`), PDS URL, or auth server URL
-
- `options` - Additional OAuth parameters (optional, see [AuthorizeOptions](#authorizeoptions))
-
- `cancelToken` - Dio cancellation token (optional)
-
-
**Returns:** `OAuthSession` - Authenticated session
-
-
**Throws:**
-
- `FormatException` - Invalid parameters
-
- `OAuthResolverError` - Identity/server resolution failed
-
- `OAuthCallbackError` - OAuth error from server
-
- `FlutterWebAuth2UserCanceled` - User cancelled browser flow
-
-
**Example:**
-
-
```dart
-
// Simple sign-in
-
final session = await client.signIn('alice.bsky.social');
-
-
// With custom state
-
final session = await client.signIn(
-
'alice.bsky.social',
-
options: AuthorizeOptions(state: 'my-app-state'),
-
);
-
```
-
-
##### `restore()`
-
-
Restore a stored session (automatically refreshes if expired).
-
-
```dart
-
Future<OAuthSession> restore(
-
String sub, {
-
dynamic refresh = 'auto',
-
CancelToken? cancelToken,
-
})
-
```
-
-
**Parameters:**
-
-
- `sub` - User's DID (e.g., `"did:plc:abc123"`)
-
- `refresh` - Token refresh strategy:
-
- `'auto'` (default) - Refresh only if expired
-
- `true` - Force refresh even if not expired
-
- `false` - Use cached tokens even if expired
-
- `cancelToken` - Dio cancellation token (optional)
-
-
**Returns:** `OAuthSession` - Restored session
-
-
**Throws:**
-
- `Exception` - Session not found
-
- `TokenRefreshError` - Refresh failed
-
- `AuthMethodUnsatisfiableError` - Auth method not supported
-
-
**Example:**
-
-
```dart
-
// Auto-refresh if expired
-
final session = await client.restore('did:plc:abc123');
-
-
// Force refresh
-
final fresh = await client.restore('did:plc:abc123', refresh: true);
-
```
-
-
##### `revoke()`
-
-
Revoke a session (sign out).
-
-
```dart
-
Future<void> revoke(
-
String sub, {
-
CancelToken? cancelToken,
-
})
-
```
-
-
**Parameters:**
-
-
- `sub` - User's DID
-
- `cancelToken` - Dio cancellation token (optional)
-
-
**Behavior:**
-
- Calls server's token revocation endpoint (best-effort)
-
- Deletes session from local storage (always)
-
- Emits `deleted` event
-
-
**Example:**
-
-
```dart
-
await client.revoke('did:plc:abc123');
-
```
-
-
#### Properties
-
-
##### `onUpdated`
-
-
Stream of session update events (token refresh, etc.).
-
-
```dart
-
Stream<SessionUpdatedEvent> get onUpdated
-
```
-
-
**Example:**
-
-
```dart
-
client.onUpdated.listen((event) {
-
print('Session ${event.sub} updated');
-
});
-
```
-
-
##### `onDeleted`
-
-
Stream of session deletion events (revoke, expiry, errors).
-
-
```dart
-
Stream<SessionDeletedEvent> get onDeleted
-
```
-
-
**Example:**
-
-
```dart
-
client.onDeleted.listen((event) {
-
print('Session ${event.sub} deleted: ${event.cause}');
-
// Navigate to sign-in screen
-
});
-
```
-
-
---
-
-
### OAuthClient (Core)
-
-
**For advanced use cases.** Provides lower-level control over the OAuth flow.
-
-
#### Constructor
-
-
```dart
-
OAuthClient(OAuthClientOptions options)
-
```
-
-
See [OAuthClientOptions](#oauthclientoptions) for all parameters.
-
-
#### Methods
-
-
##### `authorize()`
-
-
Start OAuth authorization flow (returns URL to open in browser).
-
-
```dart
-
Future<Uri> authorize(
-
String input, {
-
AuthorizeOptions? options,
-
CancelToken? cancelToken,
-
})
-
```
-
-
**Parameters:** Same as `signIn()` but returns URL instead of completing flow.
-
-
**Returns:** `Uri` - Authorization URL to open in browser
-
-
**Throws:** Same as `signIn()`
-
-
**Example:**
-
-
```dart
-
final authUrl = await client.authorize('alice.bsky.social');
-
// Open authUrl in browser yourself
-
```
-
-
##### `callback()`
-
-
Handle OAuth callback after user authorization.
-
-
```dart
-
Future<CallbackResult> callback(
-
Map<String, String> params, {
-
CallbackOptions? options,
-
CancelToken? cancelToken,
-
})
-
```
-
-
**Parameters:**
-
-
- `params` - Query/fragment parameters from callback URL
-
- `options` - Callback options (see [CallbackOptions](#callbackoptions))
-
- `cancelToken` - Dio cancellation token (optional)
-
-
**Returns:** `CallbackResult` - Contains session and app state
-
-
**Throws:**
-
- `OAuthCallbackError` - OAuth error or invalid callback
-
-
**Example:**
-
-
```dart
-
// Extract params from callback URL
-
final uri = Uri.parse(callbackUrl);
-
final params = uri.queryParameters;
-
-
// Complete OAuth flow
-
final result = await client.callback(params);
-
print('Signed in: ${result.session.sub}');
-
print('App state: ${result.state}');
-
```
-
-
##### `restore()` and `revoke()`
-
-
Same as `FlutterOAuthClient`.
-
-
#### Static Methods
-
-
##### `fetchMetadata()`
-
-
Fetch client metadata from a discoverable client ID URL.
-
-
```dart
-
static Future<Map<String, dynamic>> fetchMetadata(
-
OAuthClientFetchMetadataOptions options,
-
)
-
```
-
-
**Parameters:**
-
-
- `options.clientId` - HTTPS URL to client metadata JSON
-
- `options.dio` - Custom HTTP client (optional)
-
- `options.cancelToken` - Cancellation token (optional)
-
-
**Returns:** Client metadata as JSON
-
-
**Example:**
-
-
```dart
-
final metadata = await OAuthClient.fetchMetadata(
-
OAuthClientFetchMetadataOptions(
-
clientId: 'https://example.com/client-metadata.json',
-
),
-
);
-
```
-
-
#### Properties
-
-
Same as `FlutterOAuthClient` (`onUpdated`, `onDeleted`).
-
-
---
-
-
### Types
-
-
#### ClientMetadata
-
-
OAuth client configuration.
-
-
```dart
-
class ClientMetadata {
-
final String? clientId;
-
final List<String> redirectUris;
-
final List<String> responseTypes;
-
final List<String> grantTypes;
-
final String? scope;
-
final String tokenEndpointAuthMethod;
-
final String? tokenEndpointAuthSigningAlg;
-
final String? jwksUri;
-
final Map<String, dynamic>? jwks;
-
final String applicationType;
-
final String subjectType;
-
final String authorizationSignedResponseAlg;
-
final String? clientName;
-
final String? clientUri;
-
final String? policyUri;
-
final String? tosUri;
-
final String? logoUri;
-
final int? defaultMaxAge;
-
final bool? requireAuthTime;
-
final List<String>? contacts;
-
final bool? dpopBoundAccessTokens;
-
final List<String>? authorizationDetailsTypes;
-
-
// ... more fields
-
}
-
```
-
-
**Key Fields:**
-
-
- `clientId` - Client identifier:
-
- Discoverable: HTTPS URL to client metadata JSON (production)
-
- Loopback: `http://localhost` (development only)
-
- `redirectUris` - Array of valid redirect URIs (must match deep link configuration)
-
- `scope` - Requested scope (default: `"atproto"`, recommended: `"atproto transition:generic"`)
-
- `clientName` - Human-readable app name
-
- `dpopBoundAccessTokens` - Enable DPoP (recommended: `true`)
-
-
**Example:**
-
-
```dart
-
// Development (loopback client)
-
final metadata = ClientMetadata(
-
clientId: 'http://localhost',
-
redirectUris: ['myapp://oauth/callback'],
-
scope: 'atproto transition:generic',
-
);
-
-
// Production (discoverable client)
-
final metadata = ClientMetadata(
-
clientId: 'https://example.com/client-metadata.json',
-
redirectUris: [
-
'myapp://oauth/callback', // Custom scheme
-
'https://example.com/oauth/callback' // Universal link
-
],
-
scope: 'atproto transition:generic',
-
clientName: 'My Awesome App',
-
clientUri: 'https://example.com',
-
dpopBoundAccessTokens: true,
-
);
-
```
-
-
#### AuthorizeOptions
-
-
Additional parameters for `authorize()` / `signIn()`.
-
-
```dart
-
class AuthorizeOptions {
-
final String? redirectUri;
-
final String? state;
-
final String? scope;
-
final String? nonce;
-
final String? display;
-
final String? prompt;
-
final int? maxAge;
-
final Map<String, dynamic>? claims;
-
final String? uiLocales;
-
final String? idTokenHint;
-
final Map<String, dynamic>? authorizationDetails;
-
}
-
```
-
-
**Key Fields:**
-
-
- `redirectUri` - Override default redirect URI
-
- `state` - Application state to preserve (returned in callback)
-
- `scope` - Override default scope
-
- `display` - Display mode: `"touch"` (default for mobile), `"page"`, `"popup"`
-
- `prompt` - Prompt user: `"none"`, `"login"`, `"consent"`, `"select_account"`
-
-
**Example:**
-
-
```dart
-
final session = await client.signIn(
-
'alice.bsky.social',
-
options: AuthorizeOptions(
-
state: jsonEncode({'returnTo': '/home'}),
-
prompt: 'login', // Force re-authentication
-
),
-
);
-
```
-
-
#### CallbackOptions
-
-
Options for `callback()`.
-
-
```dart
-
class CallbackOptions {
-
final String? redirectUri;
-
}
-
```
-
-
**Note:** `redirectUri` must match the one used in `authorize()`.
-
-
#### OAuthSession
-
-
Authenticated session with token management.
-
-
```dart
-
class OAuthSession {
-
final OAuthServerAgent server;
-
final String sub; // User's DID
-
-
// Properties
-
String get did => sub;
-
Map<String, dynamic> get serverMetadata;
-
-
// Methods
-
Future<TokenInfo> getTokenInfo([dynamic refresh = 'auto']);
-
Future<void> signOut();
-
Future<http.Response> fetchHandler(
-
String pathname, {
-
String method = 'GET',
-
Map<String, String>? headers,
-
dynamic body,
-
});
-
}
-
```
-
-
**Key Methods:**
-
-
- `getTokenInfo()` - Get current token info (automatically refreshes if expired)
-
- `signOut()` - Revoke tokens and delete session
-
- `fetchHandler()` - Make authenticated HTTP request (with auto-refresh and DPoP)
-
-
**Example:**
-
-
```dart
-
final session = await client.signIn('alice.bsky.social');
-
-
// Get token info
-
final info = await session.getTokenInfo();
-
print('Expires: ${info.expiresAt}');
-
print('Scope: ${info.scope}');
-
-
// Make authenticated request
-
final response = await session.fetchHandler(
-
'/xrpc/com.atproto.repo.getRecord',
-
method: 'GET',
-
);
-
```
-
-
#### TokenInfo
-
-
Information about the current access token.
-
-
```dart
-
class TokenInfo {
-
final DateTime? expiresAt;
-
final bool? expired;
-
final String scope;
-
final String iss; // Issuer URL
-
final String aud; // Audience (PDS URL)
-
final String sub; // User's DID
-
}
-
```
-
-
---
-
-
### Errors
-
-
All errors extend `Exception` and can be caught with standard try-catch.
-
-
#### OAuthCallbackError
-
-
OAuth error from server or invalid callback.
-
-
```dart
-
class OAuthCallbackError implements Exception {
-
final String? error; // OAuth error code
-
final String? errorDescription; // Human-readable description
-
final String? errorUri; // URL with more info
-
final String? state; // App state from authorize
-
final Map<String, String> params; // All callback parameters
-
}
-
```
-
-
**Common error codes:**
-
- `access_denied` - User denied authorization
-
- `invalid_request` - Invalid parameters
-
- `server_error` - Server error
-
-
**Example:**
-
-
```dart
-
try {
-
final session = await client.signIn('alice.bsky.social');
-
} on OAuthCallbackError catch (e) {
-
if (e.error == 'access_denied') {
-
print('User cancelled sign-in');
-
} else {
-
print('OAuth error: ${e.error} - ${e.errorDescription}');
-
}
-
}
-
```
-
-
#### OAuthResolverError
-
-
Failed to resolve identity or discover OAuth server.
-
-
**When thrown:**
-
- Handle doesn't resolve
-
- DID document not found
-
- PDS URL missing from DID document
-
- OAuth server metadata not found
-
-
#### TokenRefreshError
-
-
Failed to refresh access token.
-
-
**When thrown:**
-
- Refresh token expired
-
- Refresh token revoked
-
- Network error
-
- Server error
-
-
#### TokenRevokedError
-
-
Token was revoked (intentional sign-out).
-
-
#### TokenInvalidError
-
-
Token is invalid (rejected by resource server).
-
-
#### AuthMethodUnsatisfiableError
-
-
Client authentication method not supported.
-
-
---
-
-
## Usage Guide
-
-
### Sign In Flow
-
-
Complete example with error handling:
-
-
```dart
-
import 'package:atproto_oauth_flutter/atproto_oauth_flutter.dart';
-
-
Future<void> signIn(String handle) async {
-
final client = FlutterOAuthClient(
-
clientMetadata: ClientMetadata(
-
clientId: 'http://localhost',
-
redirectUris: ['myapp://oauth/callback'],
-
scope: 'atproto transition:generic',
-
),
-
);
-
-
try {
-
final session = await client.signIn(handle);
-
-
print('โœ“ Signed in successfully!');
-
print(' DID: ${session.sub}');
-
-
final info = await session.getTokenInfo();
-
print(' Expires: ${info.expiresAt}');
-
-
} on OAuthCallbackError catch (e) {
-
if (e.error == 'access_denied') {
-
print('User denied authorization');
-
} else {
-
print('OAuth error: ${e.error}');
-
}
-
} catch (e) {
-
print('Unexpected error: $e');
-
}
-
}
-
```
-
-
### Session Restoration
-
-
Restore session when app restarts:
-
-
```dart
-
Future<OAuthSession?> restoreSession(FlutterOAuthClient client) async {
-
final did = await loadSavedDid();
-
if (did == null) return null;
-
-
try {
-
final session = await client.restore(did);
-
print('โœ“ Session restored for ${session.sub}');
-
return session;
-
-
} on TokenRefreshError catch (e) {
-
print('โŒ Session refresh failed: ${e.message}');
-
await clearSavedDid();
-
return null;
-
}
-
}
-
```
-
-
### Token Refresh
-
-
Tokens are refreshed **automatically**:
-
-
```dart
-
// Auto-refresh (default)
-
final session = await client.restore(did);
-
-
// Force refresh
-
final fresh = await client.restore(did, refresh: true);
-
-
// Check token status
-
final info = await session.getTokenInfo();
-
if (info.expired == true) {
-
print('Token will refresh on next API call');
-
}
-
```
-
-
### Sign Out (Revoke)
-
-
```dart
-
Future<void> signOut(FlutterOAuthClient client, String did) async {
-
try {
-
await client.revoke(did);
-
print('โœ“ Signed out successfully');
-
await clearSavedDid();
-
} catch (e) {
-
print('โš  Revoke failed: $e');
-
await clearSavedDid();
-
}
-
}
-
```
-
-
### Session Events
-
-
```dart
-
void setupSessionListeners(FlutterOAuthClient client) {
-
client.onUpdated.listen((event) {
-
print('Session updated: ${event.sub}');
-
});
-
-
client.onDeleted.listen((event) {
-
print('Session deleted: ${event.sub}');
-
navigateToSignIn();
-
});
-
}
-
```
-
-
---
-
-
## Advanced Usage
-
-
### Custom Storage Configuration
-
-
```dart
-
final client = FlutterOAuthClient(
-
clientMetadata: metadata,
-
secureStorage: FlutterSecureStorage(
-
iOptions: IOSOptions(
-
accessibility: KeychainAccessibility.first_unlock,
-
),
-
aOptions: AndroidOptions(
-
encryptedSharedPreferences: true,
-
),
-
),
-
);
-
```
-
-
### Direct OAuthClient Usage
-
-
For full control over the OAuth flow:
-
-
```dart
-
final client = OAuthClient(
-
OAuthClientOptions(
-
responseMode: OAuthResponseMode.query,
-
clientMetadata: metadata.toJson(),
-
stateStore: MyCustomStateStore(),
-
sessionStore: MyCustomSessionStore(),
-
runtimeImplementation: FlutterRuntime(),
-
),
-
);
-
-
// Manual flow
-
final authUrl = await client.authorize('alice.bsky.social');
-
// Open browser yourself
-
final result = await client.callback(params);
-
```
-
-
---
-
-
## Decentralization Explained
-
-
This is the **critical feature** that sets this package apart.
-
-
### The Problem: Hardcoded Servers
-
-
```dart
-
// โŒ BROKEN - Only works with bsky.social
-
const authServer = 'https://bsky.social'; // Hardcoded!
-
```
-
-
### The Solution: Dynamic Discovery
-
-
```dart
-
// โœ… CORRECT - Discovers auth server dynamically
-
await client.signIn('bob.custom-pds.com');
-
-
// What happens:
-
// 1. Resolve handle โ†’ DID
-
// 2. Fetch DID document
-
// 3. Discover PDS URL
-
// 4. Fetch PDS metadata
-
// 5. Discover authorization server
-
// 6. Complete OAuth with correct server โœ…
-
```
-
-
### Why This Matters
-
-
**atProto is decentralized.** Users can host their data on any PDS. Your app should work with ALL of them.
-
-
### Real-World Example
-
-
```dart
-
// Alice uses Bluesky
-
await client.signIn('alice.bsky.social');
-
// โ†’ https://bsky.app
-
-
// Bob runs his own
-
await client.signIn('bob.example.com');
-
// โ†’ https://auth.example.com
-
-
// All work! ๐ŸŽ‰
-
```
-
-
---
-
-
## Security Features
-
-
### Secure Token Storage
-
-
- **iOS:** Keychain with device encryption
-
- **Android:** EncryptedSharedPreferences (AES-256)
-
-
### DPoP (Token Binding)
-
-
- Binds tokens to cryptographic keys
-
- Prevents token theft
-
- Every request includes signed proof
-
-
### PKCE (Code Protection)
-
-
- SHA-256 challenge/verifier
-
- Prevents code interception
-
-
### State Parameter
-
-
- CSRF protection
-
- One-time use
-
-
---
-
-
## OAuth Flows
-
-
### Authorization Flow
-
-
```
-
App โ†’ Resolve identity โ†’ Discover servers โ†’ Generate PKCE/DPoP
-
โ†’ Open browser โ†’ User authenticates โ†’ Callback โ†’ Exchange code
-
โ†’ Store session โ†’ Return OAuthSession
-
```
-
-
### Token Refresh Flow
-
-
```
-
API call โ†’ Detect expiration โ†’ Acquire lock โ†’ Refresh tokens
-
โ†’ Update storage โ†’ Release lock โ†’ Retry API call
-
```
-
-
---
-
-
## Troubleshooting
-
-
### Deep Linking Not Working
-
-
1. Check platform configuration (Info.plist / AndroidManifest.xml)
-
2. Test manually: `xcrun simctl openurl booted "myapp://..."`
-
3. Verify URL scheme matches `redirectUris`
-
-
### OAuth Errors
-
-
- `invalid_request` - Check ClientMetadata
-
- `access_denied` - User cancelled
-
- `server_error` - Check server status
-
-
### Token Refresh Failures
-
-
- Token expired โ†’ User must re-authenticate
-
- Session auto-deleted on failure
-
-
---
-
-
## Migration Guide
-
-
### From `atproto_oauth`
-
-
**Before (Broken):**
-
```dart
-
// Only works with bsky.social
-
final session = await client.signIn('bob.custom-pds.com'); // BROKEN
-
```
-
-
**After (Fixed):**
-
```dart
-
import 'package:atproto_oauth_flutter/atproto_oauth_flutter.dart';
-
-
final client = FlutterOAuthClient(
-
clientMetadata: ClientMetadata(
-
clientId: 'http://localhost',
-
redirectUris: ['myapp://oauth/callback'],
-
),
-
);
-
-
final session = await client.signIn('bob.custom-pds.com'); // WORKS!
-
```
-
-
---
-
-
## Architecture
-
-
Built in **7 layers** matching TypeScript original:
-
-
1. **Foundation** - Types, constants, utilities
-
2. **Runtime** - Crypto abstractions, PKCE, keys
-
3. **Identity Resolution** - DID/handle โ†’ PDS discovery (**critical for decentralization**)
-
4. **OAuth Discovery** - Dynamic server metadata fetching
-
5. **DPoP** - Token binding proofs
-
6. **OAuth Flow** - Authorization, tokens, sessions
-
7. **Flutter Platform** - Secure storage, crypto implementation
-
-
---
-
-
## Examples
-
-
See `example/flutter_oauth_example.dart` for complete examples.
-
-
### Minimal Example
-
-
```dart
-
final client = FlutterOAuthClient(
-
clientMetadata: ClientMetadata(
-
clientId: 'http://localhost',
-
redirectUris: ['myapp://oauth/callback'],
-
),
-
);
-
-
final session = await client.signIn('alice.bsky.social');
-
print('Signed in: ${session.sub}');
-
```
-
-
---
-
-
## Contributing
-
-
Contributions welcome! Please:
-
1. Fork the repo
-
2. Create feature branch
-
3. Run `flutter analyze`
-
4. Submit PR
-
-
---
-
-
## License
-
-
MIT License - See LICENSE file
-
-
---
-
-
## Credits
-
-
- **Based on:** Official Bluesky [`@atproto/oauth-client`](https://github.com/bluesky-social/atproto/tree/main/packages/oauth/oauth-client)
-
- **Architecture:** 1:1 port maintaining API compatibility
-
-
---
-
-
## Status
-
-
**Version:** 0.1.0
-
**Status:** โœ… Complete - Ready for Testing
-
-
**Next:**
-
- Manual testing with real servers
-
- Unit/integration tests
-
- Publish to pub.dev
-
-
---
-
-
**Made with โค๏ธ for the decentralized web**
-220
packages/atproto_oauth_flutter/example/flutter_oauth_example.dart
···
-
/// Example usage of FlutterOAuthClient for atProto OAuth authentication.
-
///
-
/// This demonstrates the complete OAuth flow for a Flutter application:
-
/// 1. Initialize the client
-
/// 2. Sign in with a handle
-
/// 3. Use the authenticated session
-
/// 4. Restore session on app restart
-
/// 5. Sign out (revoke session)
-
-
import 'package:atproto_oauth_flutter/atproto_oauth_flutter.dart';
-
-
void main() async {
-
// ========================================================================
-
// 1. Initialize the OAuth client
-
// ========================================================================
-
-
final client = FlutterOAuthClient(
-
clientMetadata: ClientMetadata(
-
// For development: use loopback client (no client metadata URL needed)
-
clientId: 'http://localhost',
-
-
// For production: use discoverable client metadata
-
// clientId: 'https://example.com/client-metadata.json',
-
-
// Redirect URIs for your app
-
// - Custom URL scheme: myapp://oauth/callback
-
// - Universal links: https://example.com/oauth/callback
-
redirectUris: ['myapp://oauth/callback'],
-
-
// Scope: what permissions to request
-
// - 'atproto': Full atproto access
-
// - 'transition:generic': Additional permissions for legacy systems
-
scope: 'atproto transition:generic',
-
-
// Client metadata
-
clientName: 'My Awesome App',
-
clientUri: 'https://example.com',
-
-
// Token binding
-
dpopBoundAccessTokens: true, // Enable DPoP for security
-
),
-
-
// Response mode (query or fragment)
-
responseMode: OAuthResponseMode.query,
-
-
// Allow HTTP only for development (never in production!)
-
allowHttp: false,
-
);
-
-
// ========================================================================
-
// 2. Sign in with a handle
-
// ========================================================================
-
-
try {
-
print('Starting sign-in flow for alice.bsky.social...');
-
-
// This will:
-
// 1. Resolve the handle to find the authorization server
-
// 2. Generate PKCE code challenge/verifier
-
// 3. Generate DPoP key
-
// 4. Open browser for user authentication
-
// 5. Handle OAuth callback
-
// 6. Exchange authorization code for tokens
-
// 7. Store session securely
-
final session = await client.signIn('alice.bsky.social');
-
-
print('โœ“ Signed in successfully!');
-
print(' DID: ${session.sub}');
-
print(' Session info: ${session.info}');
-
-
// ========================================================================
-
// 3. Use the authenticated session
-
// ========================================================================
-
-
// The session has a PDS client you can use for authenticated requests
-
// (This requires integrating with an atproto API client library)
-
//
-
// Example:
-
// final agent = session.pdsClient;
-
// final profile = await agent.getProfile();
-
-
print('Session is ready for API calls');
-
} on OAuthCallbackError catch (e) {
-
// Handle OAuth errors (user cancelled, invalid state, etc.)
-
print('OAuth callback error: ${e.error}');
-
print('Description: ${e.errorDescription}');
-
return;
-
} catch (e) {
-
print('Sign-in error: $e');
-
return;
-
}
-
-
// ========================================================================
-
// 4. Restore session on app restart
-
// ========================================================================
-
-
// Later, when the app restarts, restore the session:
-
try {
-
final did = 'did:plc:abc123'; // Get from storage or previous session
-
-
print('Restoring session for $did...');
-
-
// This will:
-
// 1. Load session from secure storage
-
// 2. Check if tokens are expired
-
// 3. Automatically refresh if needed
-
// 4. Return authenticated session
-
final session = await client.restore(did);
-
-
print('โœ“ Session restored!');
-
print(' Access token expires: ${session.info['expiresAt']}');
-
} catch (e) {
-
print('Failed to restore session: $e');
-
// Session may have been revoked or expired
-
// Prompt user to sign in again
-
}
-
-
// ========================================================================
-
// 5. Sign out (revoke session)
-
// ========================================================================
-
-
try {
-
final did = 'did:plc:abc123';
-
-
print('Signing out $did...');
-
-
// This will:
-
// 1. Call token revocation endpoint (best effort)
-
// 2. Delete session from secure storage
-
// 3. Emit 'deleted' event
-
await client.revoke(did);
-
-
print('โœ“ Signed out successfully');
-
} catch (e) {
-
print('Sign out error: $e');
-
// Session is still deleted locally even if revocation fails
-
}
-
-
// ========================================================================
-
// Advanced: Listen to session events
-
// ========================================================================
-
-
// Listen for session updates (token refresh, etc.)
-
client.onUpdated.listen((event) {
-
print('Session updated: ${event.sub}');
-
print(' New access token received');
-
});
-
-
// Listen for session deletions (revoked, expired, etc.)
-
client.onDeleted.listen((event) {
-
print('Session deleted: ${event.sub}');
-
print(' Cause: ${event.cause}');
-
// Handle session deletion (navigate to sign-in screen, etc.)
-
});
-
-
// ========================================================================
-
// Advanced: Custom configuration
-
// ========================================================================
-
-
// You can customize storage, caching, and crypto:
-
final customClient = FlutterOAuthClient(
-
clientMetadata: ClientMetadata(
-
clientId: 'https://example.com/client-metadata.json',
-
redirectUris: ['myapp://oauth/callback'],
-
),
-
-
// Custom secure storage instance
-
secureStorage: const FlutterSecureStorage(
-
aOptions: AndroidOptions(encryptedSharedPreferences: true),
-
),
-
-
// Custom PLC directory URL (for private deployments)
-
plcDirectoryUrl: 'https://plc.example.com',
-
-
// Custom handle resolver URL
-
handleResolverUrl: 'https://bsky.social',
-
);
-
-
print('Custom client initialized');
-
-
// ========================================================================
-
// Platform configuration (iOS)
-
// ========================================================================
-
-
// iOS: Add URL scheme to Info.plist
-
// <key>CFBundleURLTypes</key>
-
// <array>
-
// <dict>
-
// <key>CFBundleURLSchemes</key>
-
// <array>
-
// <string>myapp</string>
-
// </array>
-
// </dict>
-
// </array>
-
-
// ========================================================================
-
// Platform configuration (Android)
-
// ========================================================================
-
-
// Android: Add intent filter to AndroidManifest.xml
-
// <intent-filter>
-
// <action android:name="android.intent.action.VIEW" />
-
// <category android:name="android.intent.category.DEFAULT" />
-
// <category android:name="android.intent.category.BROWSABLE" />
-
// <data android:scheme="myapp" />
-
// </intent-filter>
-
-
// ========================================================================
-
// Security best practices
-
// ========================================================================
-
-
// โœ“ Tokens stored in secure storage (Keychain/EncryptedSharedPreferences)
-
// โœ“ DPoP binds tokens to cryptographic keys
-
// โœ“ PKCE prevents authorization code interception
-
// โœ“ State parameter prevents CSRF attacks
-
// โœ“ Automatic token refresh with concurrency control
-
// โœ“ Session cleanup on errors
-
-
print('Example complete!');
-
}
-104
packages/atproto_oauth_flutter/example/identity_resolver_example.dart
···
-
/// Example usage of the atProto identity resolution layer.
-
///
-
/// This demonstrates the critical functionality for decentralization:
-
/// resolving handles and DIDs to find where user data is actually stored.
-
-
import 'package:atproto_oauth_flutter/src/identity/identity.dart';
-
-
Future<void> main() async {
-
print('=== atProto Identity Resolution Examples ===\n');
-
-
// Create an identity resolver
-
// The handleResolverUrl should point to an XRPC service that implements
-
// com.atproto.identity.resolveHandle (typically bsky.social for public resolution)
-
final resolver = AtprotoIdentityResolver.withDefaults(
-
handleResolverUrl: 'https://bsky.social',
-
);
-
-
print('Example 1: Resolve a Bluesky handle to find their PDS');
-
print('--------------------------------------------------');
-
try {
-
// This is the most common use case: find where a user's data lives
-
final pdsUrl = await resolver.resolveToPds('pfrazee.com');
-
print('Handle: pfrazee.com');
-
print('PDS URL: $pdsUrl');
-
print('โœ“ This user hosts their data on: $pdsUrl\n');
-
} catch (e) {
-
print('Error: $e\n');
-
}
-
-
print('Example 2: Get full identity information');
-
print('--------------------------------------------------');
-
try {
-
final info = await resolver.resolve('pfrazee.com');
-
print('Handle: ${info.handle}');
-
print('DID: ${info.did}');
-
print('PDS URL: ${info.pdsUrl}');
-
print('Has valid handle: ${info.hasValidHandle}');
-
print('Also known as: ${info.didDoc.alsoKnownAs}');
-
print('โœ“ Complete identity information retrieved\n');
-
} catch (e) {
-
print('Error: $e\n');
-
}
-
-
print('Example 3: Resolve from a DID');
-
print('--------------------------------------------------');
-
try {
-
// You can also start from a DID
-
final info = await resolver.resolveFromDid(
-
'did:plc:ragtjsm2j2vknwkz3zp4oxrd',
-
);
-
print('DID: ${info.did}');
-
print('Handle: ${info.handle}');
-
print('PDS URL: ${info.pdsUrl}');
-
print('โœ“ Resolved DID to handle and PDS\n');
-
} catch (e) {
-
print('Error: $e\n');
-
}
-
-
print('Example 4: Custom domain handle (CRITICAL for decentralization)');
-
print('--------------------------------------------------');
-
try {
-
// This demonstrates why this code is essential:
-
// Users can use their own domains and host on their own PDS
-
final info = await resolver.resolve('jay.bsky.team');
-
print('Handle: ${info.handle}');
-
print('DID: ${info.did}');
-
print('PDS URL: ${info.pdsUrl}');
-
print('โœ“ Custom domain resolves to custom PDS (not hardcoded!)\n');
-
} catch (e) {
-
print('Error: $e\n');
-
}
-
-
print('Example 5: Validation - Invalid handle');
-
print('--------------------------------------------------');
-
try {
-
await resolver.resolve('not-a-valid-handle');
-
} catch (e) {
-
print('โœ“ Correctly rejected invalid handle: $e\n');
-
}
-
-
print('=== Why This Matters ===');
-
print('''
-
This identity resolution layer is THE CRITICAL PIECE for atProto decentralization:
-
-
1. **No Hardcoded Servers**: Unlike broken implementations that hardcode bsky.social,
-
this correctly resolves each user's actual PDS location.
-
-
2. **Custom Domains**: Users can use their own domains (e.g., alice.example.com)
-
and host on any PDS they choose.
-
-
3. **Portability**: Users can change their PDS without losing their DID or identity.
-
The DID document always points to the current PDS location.
-
-
4. **Bi-directional Validation**: We verify that:
-
- Handle โ†’ DID resolution works
-
- DID document contains the handle
-
- Both directions match (security!)
-
-
5. **Caching**: Built-in caching prevents redundant lookups while respecting TTLs.
-
-
Without this layer, apps are locked to centralized servers. With it, atProto
-
achieves true decentralization where users control their data location.
-
''');
-
}
-104
packages/atproto_oauth_flutter/lib/atproto_oauth_flutter.dart
···
-
/// atproto OAuth client for Flutter.
-
///
-
/// This library provides OAuth authentication capabilities for AT Protocol
-
/// (atproto) applications on Flutter/Dart platforms.
-
///
-
/// This is a 1:1 port of the TypeScript @atproto/oauth-client package to Dart.
-
///
-
/// ## Quick Start
-
///
-
/// ```dart
-
/// import 'package:atproto_oauth_flutter/atproto_oauth_flutter.dart';
-
///
-
/// // 1. Initialize client
-
/// final client = FlutterOAuthClient(
-
/// clientMetadata: ClientMetadata(
-
/// clientId: 'https://example.com/client-metadata.json',
-
/// redirectUris: ['myapp://oauth/callback'],
-
/// scope: 'atproto transition:generic',
-
/// ),
-
/// );
-
///
-
/// // 2. Sign in with handle
-
/// final session = await client.signIn('alice.bsky.social');
-
/// print('Signed in as: ${session.sub}');
-
///
-
/// // 3. Use authenticated session
-
/// // (Integrate with your atproto API client)
-
///
-
/// // 4. Later: restore session
-
/// final restored = await client.restore(session.sub);
-
///
-
/// // 5. Sign out
-
/// await client.revoke(session.sub);
-
/// ```
-
///
-
/// ## Features
-
///
-
/// - Full OAuth 2.0 + OIDC support with PKCE
-
/// - DPoP (Demonstrating Proof of Possession) for token security
-
/// - Automatic token refresh
-
/// - Secure session storage (flutter_secure_storage)
-
/// - Handle and DID resolution
-
/// - PAR (Pushed Authorization Request) support
-
/// - Works with any atProto PDS or authorization server
-
///
-
/// ## Security
-
///
-
/// - Tokens stored in device secure storage (Keychain/EncryptedSharedPreferences)
-
/// - DPoP binds tokens to cryptographic keys
-
/// - PKCE prevents authorization code interception
-
/// - Automatic session cleanup on errors
-
///
-
library;
-
-
// ============================================================================
-
// Main API - Start here!
-
// ============================================================================
-
-
/// High-level Flutter OAuth client (recommended for most apps)
-
export 'src/platform/flutter_oauth_client.dart';
-
-
/// Router integration helpers (for go_router, auto_route, etc.)
-
export 'src/platform/flutter_oauth_router_helper.dart';
-
-
// ============================================================================
-
// Core OAuth Client
-
// ============================================================================
-
-
/// Core OAuth client and types (for advanced use cases)
-
export 'src/client/oauth_client.dart';
-
-
// ============================================================================
-
// Sessions
-
// ============================================================================
-
-
/// OAuth session types
-
export 'src/session/oauth_session.dart';
-
-
// ============================================================================
-
// Types
-
// ============================================================================
-
-
/// Core types and options
-
export 'src/types.dart';
-
-
// ============================================================================
-
// Platform Implementations (for custom configurations)
-
// ============================================================================
-
-
/// Storage implementations (for customization)
-
export 'src/platform/flutter_stores.dart';
-
-
/// Runtime implementation (cryptographic operations)
-
export 'src/platform/flutter_runtime.dart';
-
-
/// Key implementation (EC keys with pointycastle)
-
export 'src/platform/flutter_key.dart';
-
-
// ============================================================================
-
// Errors
-
// ============================================================================
-
-
/// All OAuth error types
-
export 'src/errors/errors.dart';
-977
packages/atproto_oauth_flutter/lib/src/client/oauth_client.dart
···
-
import 'dart:async';
-
import 'package:dio/dio.dart';
-
import 'package:flutter/foundation.dart';
-
-
import '../constants.dart';
-
import '../dpop/fetch_dpop.dart' show InMemoryStore;
-
import '../errors/auth_method_unsatisfiable_error.dart';
-
import '../errors/oauth_callback_error.dart';
-
import '../errors/token_revoked_error.dart';
-
import '../identity/constants.dart';
-
import '../identity/did_helpers.dart' show assertAtprotoDid;
-
import '../identity/did_resolver.dart' show DidCache;
-
import '../identity/handle_resolver.dart' show HandleCache;
-
import '../identity/identity_resolver.dart';
-
import '../oauth/authorization_server_metadata_resolver.dart' as auth_resolver;
-
import '../oauth/client_auth.dart';
-
import '../oauth/oauth_resolver.dart';
-
import '../oauth/oauth_server_agent.dart';
-
import '../oauth/oauth_server_factory.dart';
-
import '../oauth/protected_resource_metadata_resolver.dart';
-
import '../oauth/validate_client_metadata.dart';
-
import '../platform/flutter_key.dart';
-
import '../runtime/runtime.dart' as runtime_lib;
-
import '../runtime/runtime_implementation.dart';
-
import '../session/oauth_session.dart'
-
show OAuthSession, Session, SessionGetterInterface;
-
import '../session/session_getter.dart';
-
import '../session/state_store.dart';
-
import '../types.dart';
-
import '../util.dart';
-
-
// Re-export types needed for OAuthClientOptions
-
export '../identity/did_resolver.dart' show DidCache, DidResolver;
-
export '../identity/handle_resolver.dart' show HandleCache, HandleResolver;
-
export '../identity/identity_resolver.dart' show IdentityResolver;
-
export '../oauth/authorization_server_metadata_resolver.dart'
-
show AuthorizationServerMetadataCache;
-
export '../oauth/oauth_server_agent.dart' show DpopNonceCache;
-
export '../oauth/protected_resource_metadata_resolver.dart'
-
show ProtectedResourceMetadataCache;
-
export '../runtime/runtime_implementation.dart' show RuntimeImplementation, Key;
-
export '../oauth/client_auth.dart' show Keyset;
-
export '../session/session_getter.dart'
-
show SessionStore, SessionUpdatedEvent, SessionDeletedEvent;
-
export '../session/state_store.dart' show StateStore, InternalStateData;
-
export '../types.dart' show ClientMetadata, AuthorizeOptions, CallbackOptions;
-
-
/// OAuth response mode.
-
enum OAuthResponseMode {
-
/// Parameters in query string (default, most compatible)
-
query('query'),
-
-
/// Parameters in URL fragment (for single-page apps)
-
fragment('fragment');
-
-
final String value;
-
const OAuthResponseMode(this.value);
-
-
@override
-
String toString() => value;
-
}
-
-
/// Options for constructing an OAuthClient.
-
///
-
/// This includes all configuration, storage, and service dependencies
-
/// needed to implement the complete OAuth flow.
-
class OAuthClientOptions {
-
// Config
-
/// Response mode for OAuth (query or fragment)
-
final OAuthResponseMode responseMode;
-
-
/// Client metadata (validated before use)
-
final Map<String, dynamic> clientMetadata;
-
-
/// Optional keyset for confidential clients (private_key_jwt)
-
final Keyset? keyset;
-
-
/// Whether to allow HTTP connections (for development only)
-
///
-
/// This affects:
-
/// - OAuth authorization/resource servers
-
/// - did:web document fetching
-
///
-
/// Note: PLC directory connections are controlled separately.
-
final bool allowHttp;
-
-
// Stores
-
/// Storage for OAuth state during authorization flow
-
final StateStore stateStore;
-
-
/// Storage for session tokens
-
final SessionStore sessionStore;
-
-
/// Optional cache for authorization server metadata
-
final auth_resolver.AuthorizationServerMetadataCache?
-
authorizationServerMetadataCache;
-
-
/// Optional cache for protected resource metadata
-
final ProtectedResourceMetadataCache? protectedResourceMetadataCache;
-
-
/// Optional cache for DPoP nonces
-
final DpopNonceCache? dpopNonceCache;
-
-
/// Optional cache for DID documents
-
final DidCache? didCache;
-
-
/// Optional cache for handle โ†’ DID resolutions
-
final HandleCache? handleCache;
-
-
// Services
-
/// Platform-specific cryptographic operations
-
final RuntimeImplementation runtimeImplementation;
-
-
/// Optional HTTP client (Dio instance)
-
final Dio? dio;
-
-
/// Optional custom identity resolver
-
final IdentityResolver? identityResolver;
-
-
/// PLC directory URL (for DID resolution)
-
final String? plcDirectoryUrl;
-
-
/// Handle resolver URL (for handle โ†’ DID resolution)
-
final String? handleResolverUrl;
-
-
const OAuthClientOptions({
-
required this.responseMode,
-
required this.clientMetadata,
-
this.keyset,
-
this.allowHttp = false,
-
required this.stateStore,
-
required this.sessionStore,
-
this.authorizationServerMetadataCache,
-
this.protectedResourceMetadataCache,
-
this.dpopNonceCache,
-
this.didCache,
-
this.handleCache,
-
required this.runtimeImplementation,
-
this.dio,
-
this.identityResolver,
-
this.plcDirectoryUrl,
-
this.handleResolverUrl,
-
});
-
}
-
-
/// Result of a successful OAuth callback.
-
class CallbackResult {
-
/// The authenticated session
-
final OAuthSession session;
-
-
/// The application state from the original authorize call
-
final String? state;
-
-
const CallbackResult({required this.session, this.state});
-
}
-
-
/// Options for fetching client metadata from a discoverable client ID.
-
class OAuthClientFetchMetadataOptions {
-
/// The discoverable client ID (HTTPS URL)
-
final String clientId;
-
-
/// Optional HTTP client
-
final Dio? dio;
-
-
/// Optional cancellation token
-
final CancelToken? cancelToken;
-
-
const OAuthClientFetchMetadataOptions({
-
required this.clientId,
-
this.dio,
-
this.cancelToken,
-
});
-
}
-
-
/// Main OAuth client for atProto OAuth flows.
-
///
-
/// This is the primary class that developers interact with. It orchestrates:
-
/// - Authorization flow (authorize โ†’ callback)
-
/// - Session restoration (restore)
-
/// - Token revocation (revoke)
-
/// - Session lifecycle events
-
///
-
/// Usage:
-
/// ```dart
-
/// final client = OAuthClient(
-
/// clientMetadata: {
-
/// 'client_id': 'https://example.com/client-metadata.json',
-
/// 'redirect_uris': ['myapp://oauth/callback'],
-
/// 'scope': 'atproto',
-
/// },
-
/// responseMode: OAuthResponseMode.query,
-
/// stateStore: MyStateStore(),
-
/// sessionStore: MySessionStore(),
-
/// runtimeImplementation: MyRuntimeImplementation(),
-
/// );
-
///
-
/// // Start authorization
-
/// final authUrl = await client.authorize('alice.bsky.social');
-
///
-
/// // Handle callback
-
/// final result = await client.callback(callbackParams);
-
/// print('Signed in as: ${result.session.sub}');
-
///
-
/// // Restore session later
-
/// final session = await client.restore('did:plc:abc123');
-
///
-
/// // Revoke session
-
/// await client.revoke('did:plc:abc123');
-
/// ```
-
class OAuthClient extends CustomEventTarget<Map<String, dynamic>> {
-
// Config
-
/// Validated client metadata
-
final ClientMetadata clientMetadata;
-
-
/// OAuth response mode (query or fragment)
-
final OAuthResponseMode responseMode;
-
-
/// Optional keyset for confidential clients
-
final Keyset? keyset;
-
-
// Services
-
/// Runtime for cryptographic operations
-
final runtime_lib.Runtime runtime;
-
-
/// HTTP client
-
final Dio dio;
-
-
/// OAuth resolver for identity โ†’ metadata
-
final OAuthResolver oauthResolver;
-
-
/// Factory for creating OAuth server agents
-
final OAuthServerFactory serverFactory;
-
-
// Stores
-
/// Session management with automatic refresh
-
final SessionGetter _sessionGetter;
-
-
/// OAuth state storage
-
final StateStore _stateStore;
-
-
// Event streams
-
final StreamController<SessionUpdatedEvent> _updatedController =
-
StreamController<SessionUpdatedEvent>.broadcast();
-
final StreamController<SessionDeletedEvent> _deletedController =
-
StreamController<SessionDeletedEvent>.broadcast();
-
-
/// Stream of session update events
-
Stream<SessionUpdatedEvent> get onUpdated => _updatedController.stream;
-
-
/// Stream of session deletion events
-
Stream<SessionDeletedEvent> get onDeleted => _deletedController.stream;
-
-
/// Constructs an OAuthClient with the given options.
-
///
-
/// Throws [FormatException] if client metadata is invalid.
-
/// Throws [TypeError] if keyset configuration is incorrect.
-
OAuthClient(OAuthClientOptions options)
-
: keyset = options.keyset,
-
responseMode = options.responseMode,
-
runtime = runtime_lib.Runtime(options.runtimeImplementation),
-
dio = options.dio ?? Dio(),
-
_stateStore = options.stateStore,
-
clientMetadata = validateClientMetadata(
-
options.clientMetadata,
-
options.keyset,
-
),
-
oauthResolver = _createOAuthResolver(options),
-
serverFactory = _createServerFactory(options),
-
_sessionGetter = _createSessionGetter(options) {
-
// Proxy session events from SessionGetter
-
_sessionGetter.onUpdated.listen((event) {
-
_updatedController.add(event);
-
dispatchCustomEvent('updated', event);
-
});
-
-
_sessionGetter.onDeleted.listen((event) {
-
_deletedController.add(event);
-
dispatchCustomEvent('deleted', event);
-
});
-
}
-
-
/// Creates the OAuth resolver.
-
static OAuthResolver _createOAuthResolver(OAuthClientOptions options) {
-
final dio = options.dio ?? Dio();
-
-
return OAuthResolver(
-
identityResolver:
-
options.identityResolver ??
-
AtprotoIdentityResolver.withDefaults(
-
handleResolverUrl:
-
options.handleResolverUrl ?? 'https://bsky.social',
-
plcDirectoryUrl: options.plcDirectoryUrl,
-
dio: dio,
-
didCache: options.didCache,
-
handleCache: options.handleCache,
-
),
-
protectedResourceMetadataResolver: OAuthProtectedResourceMetadataResolver(
-
options.protectedResourceMetadataCache ??
-
InMemoryStore<String, Map<String, dynamic>>(),
-
dio: dio,
-
config: OAuthProtectedResourceMetadataResolverConfig(
-
allowHttpResource: options.allowHttp,
-
),
-
),
-
authorizationServerMetadataResolver:
-
auth_resolver.OAuthAuthorizationServerMetadataResolver(
-
options.authorizationServerMetadataCache ??
-
InMemoryStore<String, Map<String, dynamic>>(),
-
dio: dio,
-
config:
-
auth_resolver.OAuthAuthorizationServerMetadataResolverConfig(
-
allowHttpIssuer: options.allowHttp,
-
),
-
),
-
);
-
}
-
-
/// Creates the OAuth server factory.
-
static OAuthServerFactory _createServerFactory(OAuthClientOptions options) {
-
return OAuthServerFactory(
-
clientMetadata: validateClientMetadata(
-
options.clientMetadata,
-
options.keyset,
-
),
-
runtime: runtime_lib.Runtime(options.runtimeImplementation),
-
resolver: _createOAuthResolver(options),
-
dio: options.dio ?? Dio(),
-
keyset: options.keyset,
-
dpopNonceCache: options.dpopNonceCache ?? InMemoryStore<String, String>(),
-
);
-
}
-
-
/// Creates the session getter.
-
static SessionGetter _createSessionGetter(OAuthClientOptions options) {
-
return SessionGetter(
-
sessionStore: options.sessionStore,
-
serverFactory: _createServerFactory(options),
-
runtime: runtime_lib.Runtime(options.runtimeImplementation),
-
);
-
}
-
-
/// Fetches client metadata from a discoverable client ID URL.
-
///
-
/// This is a static helper method for fetching metadata before
-
/// constructing the OAuthClient.
-
///
-
/// See: https://datatracker.ietf.org/doc/draft-ietf-oauth-client-id-metadata-document/
-
static Future<Map<String, dynamic>> fetchMetadata(
-
OAuthClientFetchMetadataOptions options,
-
) async {
-
final dio = options.dio ?? Dio();
-
final clientId = options.clientId;
-
-
try {
-
final response = await dio.getUri<Map<String, dynamic>>(
-
Uri.parse(clientId),
-
options: Options(
-
followRedirects: false,
-
validateStatus: (status) => status == 200,
-
responseType: ResponseType.json,
-
),
-
cancelToken: options.cancelToken,
-
);
-
-
// Validate content type
-
final contentType = response.headers.value('content-type');
-
final mime = contentType?.split(';')[0].trim();
-
if (mime != 'application/json') {
-
throw FormatException('Invalid client metadata content type: $mime');
-
}
-
-
final data = response.data;
-
if (data == null) {
-
throw FormatException('Empty client metadata response');
-
}
-
-
return data;
-
} catch (e) {
-
if (e is DioException) {
-
throw Exception('Failed to fetch client metadata: ${e.message}');
-
}
-
rethrow;
-
}
-
}
-
-
/// Exposes the identity resolver for convenience.
-
IdentityResolver get identityResolver => oauthResolver.identityResolver;
-
-
/// Returns the public JWKS for this client (for confidential clients).
-
///
-
/// This is the JWKS that should be published at the client's jwks_uri
-
/// or included in the client metadata.
-
Map<String, dynamic> get jwks {
-
if (keyset == null) {
-
return {'keys': <Map<String, dynamic>>[]};
-
}
-
return keyset!.toJSON();
-
}
-
-
/// Initiates an OAuth authorization flow.
-
///
-
/// This method:
-
/// 1. Resolves the input (handle, DID, or URL) to OAuth metadata
-
/// 2. Generates PKCE parameters
-
/// 3. Generates DPoP key
-
/// 4. Negotiates client authentication method
-
/// 5. Stores internal state
-
/// 6. Uses PAR (Pushed Authorization Request) if supported
-
/// 7. Returns the authorization URL to open in a browser
-
///
-
/// The [input] can be:
-
/// - An atProto handle (e.g., "alice.bsky.social")
-
/// - A DID (e.g., "did:plc:...")
-
/// - A PDS URL (e.g., "https://pds.example.com")
-
/// - An authorization server URL (e.g., "https://auth.example.com")
-
///
-
/// The [options] can specify:
-
/// - redirectUri: Override the default redirect URI
-
/// - state: Application state to preserve
-
/// - scope: Override the default scope
-
/// - Other OIDC parameters (prompt, display, etc.)
-
///
-
/// Throws [FormatException] if parameters are invalid.
-
/// Throws [OAuthResolverError] if resolution fails.
-
Future<Uri> authorize(
-
String input, {
-
AuthorizeOptions? options,
-
CancelToken? cancelToken,
-
}) async {
-
final opts = options ?? const AuthorizeOptions();
-
-
// Validate redirect URI
-
final redirectUri = opts.redirectUri ?? clientMetadata.redirectUris.first;
-
if (!clientMetadata.redirectUris.contains(redirectUri)) {
-
throw FormatException('Invalid redirect_uri: $redirectUri');
-
}
-
-
// Resolve input to OAuth metadata
-
final resolved = await oauthResolver.resolve(
-
input,
-
auth_resolver.GetCachedOptions(cancelToken: cancelToken),
-
);
-
-
final metadata = resolved.metadata;
-
-
// Generate PKCE
-
final pkce = await runtime.generatePKCE();
-
-
// Generate DPoP key
-
final dpopAlgs = metadata['dpop_signing_alg_values_supported'] as List?;
-
final dpopKey = await runtime.generateKey(
-
dpopAlgs?.cast<String>() ?? [fallbackAlg],
-
);
-
-
// Compute DPoP JWK thumbprint for authorization requests.
-
// Required by RFC 9449 ยง7 to bind the subsequently issued code to this key.
-
final bareJwk = dpopKey.bareJwk;
-
if (bareJwk == null) {
-
throw StateError('DPoP key must provide a public JWK representation');
-
}
-
final generatedDpopJkt = await runtime.calculateJwkThumbprint(bareJwk);
-
-
// Negotiate client authentication method
-
final authMethod = negotiateClientAuthMethod(
-
metadata,
-
clientMetadata,
-
keyset,
-
);
-
-
// Generate state parameter
-
final state = await runtime.generateNonce();
-
-
// Store internal state for callback validation
-
// IMPORTANT: Store the FULL private JWK, not just bareJwk (public key only)
-
// We need the private key to restore the DPoP key during token exchange
-
final dpopKeyJwk = (dpopKey as dynamic).privateJwk ?? dpopKey.bareJwk ?? {};
-
-
if (kDebugMode) {
-
print('๐Ÿ”‘ Storing DPoP key for authorization flow');
-
}
-
-
await _stateStore.set(
-
state,
-
InternalStateData(
-
iss: metadata['issuer'] as String,
-
dpopKey: dpopKeyJwk,
-
authMethod: authMethod.toJson(),
-
verifier: pkce['verifier'] as String,
-
redirectUri: redirectUri, // Store the exact redirectUri used in PAR
-
appState: opts.state,
-
),
-
);
-
-
// Build authorization request parameters
-
final parameters = <String, String>{
-
'client_id': clientMetadata.clientId!,
-
'redirect_uri': redirectUri,
-
'code_challenge': pkce['challenge'] as String,
-
'code_challenge_method': pkce['method'] as String,
-
'state': state,
-
'response_mode': responseMode.value,
-
'response_type': 'code',
-
'scope': opts.scope ?? clientMetadata.scope ?? 'atproto',
-
'dpop_jkt': opts.dpopJkt ?? generatedDpopJkt,
-
};
-
-
// Add login hint if we have identity info
-
if (resolved.identityInfo != null) {
-
final handle = resolved.identityInfo!.handle;
-
final did = resolved.identityInfo!.did;
-
if (handle != handleInvalid) {
-
parameters['login_hint'] = handle;
-
} else {
-
parameters['login_hint'] = did;
-
}
-
}
-
-
// Add optional parameters from options
-
if (opts.nonce != null) parameters['nonce'] = opts.nonce!;
-
if (opts.display != null) parameters['display'] = opts.display!;
-
if (opts.prompt != null) parameters['prompt'] = opts.prompt!;
-
if (opts.maxAge != null) parameters['max_age'] = opts.maxAge.toString();
-
if (opts.uiLocales != null) parameters['ui_locales'] = opts.uiLocales!;
-
if (opts.idTokenHint != null) {
-
parameters['id_token_hint'] = opts.idTokenHint!;
-
}
-
-
// Build authorization URL
-
final authorizationUrl = Uri.parse(
-
metadata['authorization_endpoint'] as String,
-
);
-
-
// Validate authorization endpoint protocol
-
if (authorizationUrl.scheme != 'https' &&
-
authorizationUrl.scheme != 'http') {
-
throw FormatException(
-
'Invalid authorization endpoint protocol: ${authorizationUrl.scheme}',
-
);
-
}
-
-
// Use PAR (Pushed Authorization Request) if supported
-
final parEndpoint =
-
metadata['pushed_authorization_request_endpoint'] as String?;
-
final requiresPar =
-
metadata['require_pushed_authorization_requests'] as bool? ?? false;
-
-
if (parEndpoint != null) {
-
// Server supports PAR, use it
-
final server = await serverFactory.fromMetadata(
-
metadata,
-
authMethod,
-
dpopKey,
-
);
-
-
final parResponse = await server.request(
-
'pushed_authorization_request',
-
parameters,
-
);
-
-
final requestUri = parResponse['request_uri'] as String;
-
-
// Return simplified URL with just request_uri
-
return authorizationUrl.replace(
-
queryParameters: {
-
'client_id': clientMetadata.clientId!,
-
'request_uri': requestUri,
-
},
-
);
-
} else if (requiresPar) {
-
throw Exception(
-
'Server requires pushed authorization requests (PAR) but no PAR endpoint is available',
-
);
-
} else {
-
// No PAR support, use direct authorization request
-
final fullUrl = authorizationUrl.replace(queryParameters: parameters);
-
-
// Check URL length (2048 byte limit for some browsers)
-
final urlLength = fullUrl.toString().length;
-
if (urlLength >= 2048) {
-
throw Exception('Login URL too long ($urlLength bytes)');
-
}
-
-
return fullUrl;
-
}
-
}
-
-
/// Handles the OAuth callback after user authorization.
-
///
-
/// This method:
-
/// 1. Validates the state parameter
-
/// 2. Retrieves stored internal state
-
/// 3. Checks for error responses
-
/// 4. Validates issuer (if provided)
-
/// 5. Exchanges authorization code for tokens
-
/// 6. Creates and stores session
-
/// 7. Cleans up state
-
///
-
/// The [params] should be the query parameters from the callback URL.
-
///
-
/// The [options] can specify:
-
/// - redirectUri: Must match the one used in authorize()
-
///
-
/// Returns a [CallbackResult] with the session and application state.
-
///
-
/// Throws [OAuthCallbackError] if the callback contains errors or is invalid.
-
Future<CallbackResult> callback(
-
Map<String, String> params, {
-
CallbackOptions? options,
-
CancelToken? cancelToken,
-
}) async {
-
final opts = options ?? const CallbackOptions();
-
-
// Check for JARM (not supported)
-
final responseJwt = params['response'];
-
if (responseJwt != null) {
-
throw OAuthCallbackError(params, message: 'JARM not supported');
-
}
-
-
// Extract parameters
-
final issuerParam = params['iss'];
-
final stateParam = params['state'];
-
final errorParam = params['error'];
-
final codeParam = params['code'];
-
-
// Validate state parameter
-
if (stateParam == null) {
-
throw OAuthCallbackError(params, message: 'Missing "state" parameter');
-
}
-
-
// Retrieve internal state
-
final stateData = await _stateStore.get(stateParam);
-
if (stateData == null) {
-
throw OAuthCallbackError(
-
params,
-
message: 'Unknown authorization session "$stateParam"',
-
);
-
}
-
-
// Prevent replay attacks - delete state immediately
-
await _stateStore.del(stateParam);
-
-
try {
-
// Check for error response
-
if (errorParam != null) {
-
throw OAuthCallbackError(params, state: stateData.appState);
-
}
-
-
// Validate authorization code
-
if (codeParam == null) {
-
throw OAuthCallbackError(
-
params,
-
message: 'Missing "code" query param',
-
state: stateData.appState,
-
);
-
}
-
-
// Create OAuth server agent
-
final authMethod =
-
stateData.authMethod != null
-
? ClientAuthMethod.fromJson(
-
stateData.authMethod as Map<String, dynamic>,
-
)
-
: const ClientAuthMethod.none(); // Legacy fallback
-
-
// Restore dpopKey from stored private JWK
-
// Restore DPoP key with error handling for corrupted JWK data
-
final FlutterKey dpopKey;
-
try {
-
dpopKey = FlutterKey.fromJwk(stateData.dpopKey as Map<String, dynamic>);
-
if (kDebugMode) {
-
print('๐Ÿ”“ DPoP key restored successfully for token exchange');
-
}
-
} catch (e) {
-
throw Exception(
-
'Failed to restore DPoP key from stored state: $e. '
-
'The stored key may be corrupted. Please try authenticating again.',
-
);
-
}
-
-
final server = await serverFactory.fromIssuer(
-
stateData.iss,
-
authMethod,
-
dpopKey,
-
auth_resolver.GetCachedOptions(cancelToken: cancelToken),
-
);
-
-
// Validate issuer if provided
-
if (issuerParam != null) {
-
if (server.issuer.isEmpty) {
-
throw OAuthCallbackError(
-
params,
-
message: 'Issuer not found in metadata',
-
state: stateData.appState,
-
);
-
}
-
if (server.issuer != issuerParam) {
-
throw OAuthCallbackError(
-
params,
-
message: 'Issuer mismatch',
-
state: stateData.appState,
-
);
-
}
-
} else if (server
-
.serverMetadata['authorization_response_iss_parameter_supported'] ==
-
true) {
-
throw OAuthCallbackError(
-
params,
-
message: 'iss missing from the response',
-
state: stateData.appState,
-
);
-
}
-
-
// Exchange authorization code for tokens
-
// CRITICAL: Use the EXACT same redirectUri that was used during authorization
-
// The redirectUri in the token exchange MUST match the one in the PAR request
-
final redirectUriForExchange =
-
stateData.redirectUri ??
-
opts.redirectUri ??
-
clientMetadata.redirectUris.first;
-
-
if (kDebugMode) {
-
print('๐Ÿ”„ Exchanging authorization code for tokens:');
-
print(' Code: ${codeParam.substring(0, 20)}...');
-
print(
-
' Code verifier: ${stateData.verifier?.substring(0, 20) ?? "none"}...',
-
);
-
print(' Redirect URI: $redirectUriForExchange');
-
print(
-
' Redirect URI source: ${stateData.redirectUri != null ? "stored" : "fallback"}',
-
);
-
print(' Issuer: ${server.issuer}');
-
}
-
-
final tokenSet = await server.exchangeCode(
-
codeParam,
-
codeVerifier: stateData.verifier,
-
redirectUri: redirectUriForExchange,
-
);
-
-
try {
-
if (kDebugMode) {
-
print('๐Ÿ’พ Storing session for: ${tokenSet.sub}');
-
}
-
-
// Store session
-
await _sessionGetter.setStored(
-
tokenSet.sub,
-
Session(
-
dpopKey: stateData.dpopKey,
-
authMethod: authMethod.toJson(),
-
tokenSet: tokenSet,
-
),
-
);
-
-
if (kDebugMode) {
-
print('โœ… Session stored successfully');
-
print('๐ŸŽฏ Creating session wrapper...');
-
}
-
-
// Create session wrapper
-
final session = _createSession(server, tokenSet.sub);
-
-
if (kDebugMode) {
-
print('โœ… Session wrapper created');
-
print('๐ŸŽ‰ OAuth callback complete!');
-
}
-
-
return CallbackResult(session: session, state: stateData.appState);
-
} catch (err, stackTrace) {
-
// If session storage failed, revoke the tokens
-
if (kDebugMode) {
-
print('โŒ Session storage/creation failed:');
-
print(' Error: $err');
-
print(' Stack trace: $stackTrace');
-
}
-
await server.revoke(tokenSet.refreshToken ?? tokenSet.accessToken);
-
rethrow;
-
}
-
} catch (err, stackTrace) {
-
// Ensure appState is available in error
-
if (kDebugMode) {
-
print('โŒ Callback error (outer catch):');
-
print(' Error type: ${err.runtimeType}');
-
print(' Error: $err');
-
print(' Stack trace: $stackTrace');
-
}
-
throw OAuthCallbackError.from(err, params, stateData.appState);
-
}
-
}
-
-
/// Restores a stored session.
-
///
-
/// This method:
-
/// 1. Retrieves session from storage
-
/// 2. Checks if tokens are expired
-
/// 3. Automatically refreshes tokens if needed (based on [refresh])
-
/// 4. Creates OAuthServerAgent
-
/// 5. Returns live OAuthSession
-
///
-
/// The [sub] is the user's DID.
-
///
-
/// The [refresh] parameter controls token refresh:
-
/// - `true`: Force refresh even if not expired
-
/// - `false`: Use cached tokens even if expired
-
/// - `'auto'`: Refresh only if expired (default)
-
///
-
/// Throws [Exception] if session doesn't exist.
-
/// Throws [TokenRefreshError] if refresh fails.
-
/// Throws [AuthMethodUnsatisfiableError] if auth method can't be satisfied.
-
Future<OAuthSession> restore(
-
String sub, {
-
dynamic refresh = 'auto',
-
CancelToken? cancelToken,
-
}) async {
-
// Validate DID format
-
assertAtprotoDid(sub);
-
-
// Get session (automatically refreshes if needed based on refresh param)
-
final session = await _sessionGetter.getSession(sub, refresh);
-
-
try {
-
// Determine auth method (with legacy fallback)
-
final authMethod =
-
session.authMethod != null
-
? ClientAuthMethod.fromJson(
-
session.authMethod as Map<String, dynamic>,
-
)
-
: const ClientAuthMethod.none(); // Legacy
-
-
// Restore dpopKey from stored private JWK with error handling
-
// CRITICAL FIX: Use the stored key instead of generating a new one
-
// This ensures DPoP proofs match the token binding
-
final FlutterKey dpopKey;
-
try {
-
dpopKey = FlutterKey.fromJwk(session.dpopKey as Map<String, dynamic>);
-
} catch (e) {
-
// If key is corrupted, delete the session and force re-authentication
-
await _sessionGetter.delStored(
-
sub,
-
Exception('Corrupted DPoP key in stored session: $e'),
-
);
-
throw Exception(
-
'Failed to restore DPoP key for session. The stored key is corrupted. '
-
'Please authenticate again.',
-
);
-
}
-
-
// Create server agent
-
final server = await serverFactory.fromIssuer(
-
session.tokenSet.iss,
-
authMethod,
-
dpopKey,
-
auth_resolver.GetCachedOptions(
-
noCache: refresh == true,
-
allowStale: refresh == false,
-
cancelToken: cancelToken,
-
),
-
);
-
-
return _createSession(server, sub);
-
} catch (err) {
-
// If auth method can't be satisfied, delete the session
-
if (err is AuthMethodUnsatisfiableError) {
-
await _sessionGetter.delStored(sub, err);
-
}
-
rethrow;
-
}
-
}
-
-
/// Revokes a session.
-
///
-
/// This method:
-
/// 1. Retrieves session from storage
-
/// 2. Calls token revocation endpoint
-
/// 3. Deletes session from storage
-
///
-
/// The [sub] is the user's DID.
-
///
-
/// Token revocation is best-effort - even if the revocation request fails,
-
/// the local session is still deleted.
-
Future<void> revoke(String sub, {CancelToken? cancelToken}) async {
-
// Validate DID format
-
assertAtprotoDid(sub);
-
-
// Get session (allow stale tokens for revocation)
-
final session = await _sessionGetter.get(
-
sub,
-
const GetCachedOptions(allowStale: true),
-
);
-
-
// Try to revoke tokens on the server
-
try {
-
final authMethod =
-
session.authMethod != null
-
? ClientAuthMethod.fromJson(
-
session.authMethod as Map<String, dynamic>,
-
)
-
: const ClientAuthMethod.none(); // Legacy
-
-
// Restore dpopKey from stored private JWK with error handling
-
// CRITICAL FIX: Use the stored key instead of generating a new one
-
// This ensures DPoP proofs match the token binding
-
final FlutterKey dpopKey;
-
try {
-
dpopKey = FlutterKey.fromJwk(session.dpopKey as Map<String, dynamic>);
-
} catch (e) {
-
// If key is corrupted, skip server-side revocation
-
// The finally block will still delete the local session
-
if (kDebugMode) {
-
print('โš ๏ธ Cannot revoke on server: corrupted DPoP key ($e)');
-
print(' Local session will still be deleted');
-
}
-
return;
-
}
-
-
final server = await serverFactory.fromIssuer(
-
session.tokenSet.iss,
-
authMethod,
-
dpopKey,
-
auth_resolver.GetCachedOptions(cancelToken: cancelToken),
-
);
-
-
await server.revoke(session.tokenSet.accessToken);
-
} finally {
-
// Always delete local session, even if revocation failed
-
await _sessionGetter.delStored(sub, TokenRevokedError(sub));
-
}
-
}
-
-
/// Creates an OAuthSession wrapper.
-
///
-
/// Internal helper for creating session objects from server agents.
-
OAuthSession _createSession(OAuthServerAgent server, String sub) {
-
// Create a wrapper that implements SessionGetterInterface
-
final sessionGetterWrapper = _SessionGetterWrapper(_sessionGetter);
-
-
return OAuthSession(
-
server: server,
-
sub: sub,
-
sessionGetter: sessionGetterWrapper,
-
);
-
}
-
-
/// Disposes of resources used by this client.
-
///
-
/// Call this when the client is no longer needed to prevent memory leaks.
-
@override
-
void dispose() {
-
_updatedController.close();
-
_deletedController.close();
-
_sessionGetter.dispose();
-
super.dispose();
-
}
-
}
-
-
/// Wrapper to adapt SessionGetter to SessionGetterInterface
-
class _SessionGetterWrapper implements SessionGetterInterface {
-
final SessionGetter _getter;
-
-
_SessionGetterWrapper(this._getter);
-
-
@override
-
Future<Session> get(String sub, {bool? noCache, bool? allowStale}) async {
-
return _getter.get(
-
sub,
-
GetCachedOptions(
-
noCache: noCache ?? false,
-
allowStale: allowStale ?? false,
-
),
-
);
-
}
-
-
@override
-
Future<void> delStored(String sub, [Object? cause]) {
-
return _getter.delStored(sub, cause);
-
}
-
}
-2
packages/atproto_oauth_flutter/lib/src/constants.dart
···
-
/// Per ATProto spec (OpenID uses RS256)
-
const String fallbackAlg = 'ES256';
-593
packages/atproto_oauth_flutter/lib/src/dpop/fetch_dpop.dart
···
-
import 'dart:async';
-
import 'dart:convert';
-
-
import 'package:dio/dio.dart';
-
import 'package:flutter/foundation.dart' hide Key;
-
-
import '../runtime/runtime_implementation.dart';
-
-
/// A simple key-value store interface for storing DPoP nonces.
-
///
-
/// This is a simplified Dart version of @atproto-labs/simple-store.
-
/// Implementations can use:
-
/// - In-memory Map (for testing)
-
/// - SharedPreferences (for persistence)
-
/// - Secure storage (for sensitive data)
-
abstract class SimpleStore<K, V> {
-
/// Get a value by key. Returns null if not found.
-
FutureOr<V?> get(K key);
-
-
/// Set a value for a key.
-
FutureOr<void> set(K key, V value);
-
-
/// Delete a value by key.
-
FutureOr<void> del(K key);
-
-
/// Clear all values (optional).
-
FutureOr<void> clear();
-
}
-
-
/// In-memory implementation of SimpleStore for DPoP nonces.
-
///
-
/// This is used as the default nonce store. Nonces are ephemeral and
-
/// don't need to be persisted across app restarts.
-
class InMemoryStore<K, V> implements SimpleStore<K, V> {
-
final Map<K, V> _store = {};
-
-
@override
-
V? get(K key) => _store[key];
-
-
@override
-
void set(K key, V value) => _store[key] = value;
-
-
@override
-
void del(K key) => _store.remove(key);
-
-
@override
-
void clear() => _store.clear();
-
}
-
-
/// Options for configuring the DPoP fetch wrapper.
-
class DpopFetchWrapperOptions {
-
/// The cryptographic key used to sign DPoP proofs.
-
final Key key;
-
-
/// Store for caching DPoP nonces per origin.
-
final SimpleStore<String, String> nonces;
-
-
/// List of algorithms supported by the server (optional).
-
/// If not provided, the key's first algorithm will be used.
-
final List<String>? supportedAlgs;
-
-
/// Function to compute SHA-256 hash (required for DPoP).
-
/// Should return base64url-encoded hash.
-
final Future<String> Function(String input) sha256;
-
-
/// Whether the target server is an authorization server (true)
-
/// or resource server (false).
-
///
-
/// This affects how "use_dpop_nonce" errors are detected:
-
/// - Authorization servers return 400 with JSON error
-
/// - Resource servers return 401 with WWW-Authenticate header
-
///
-
/// If null, both patterns will be checked.
-
final bool? isAuthServer;
-
-
const DpopFetchWrapperOptions({
-
required this.key,
-
required this.nonces,
-
this.supportedAlgs,
-
required this.sha256,
-
this.isAuthServer,
-
});
-
}
-
-
/// Creates a Dio interceptor that adds DPoP (Demonstrating Proof of Possession)
-
/// headers to HTTP requests.
-
///
-
/// DPoP is a security mechanism that binds access tokens to cryptographic keys,
-
/// preventing token theft and replay attacks. It works by:
-
///
-
/// 1. Creating a JWT proof signed with a private key
-
/// 2. Including the proof in a DPoP header
-
/// 3. Including the access token hash (ath) in the proof
-
/// 4. Handling nonce-based replay protection
-
///
-
/// The interceptor automatically:
-
/// - Generates DPoP proofs for each request
-
/// - Caches and reuses server-provided nonces
-
/// - Retries requests when server requires a fresh nonce
-
/// - Handles both authorization and resource server error formats
-
///
-
/// See: https://datatracker.ietf.org/doc/html/rfc9449
-
///
-
/// Example:
-
/// ```dart
-
/// final dio = Dio();
-
/// final options = DpopFetchWrapperOptions(
-
/// key: myKey,
-
/// nonces: InMemoryStore(),
-
/// sha256: runtime.sha256,
-
/// );
-
/// dio.interceptors.add(createDpopInterceptor(options));
-
/// ```
-
Interceptor createDpopInterceptor(DpopFetchWrapperOptions options) {
-
// Negotiate algorithm once at creation time
-
final alg = _negotiateAlg(options.key, options.supportedAlgs);
-
-
return InterceptorsWrapper(
-
onRequest: (requestOptions, handler) async {
-
try {
-
// Extract authorization header for ath calculation
-
final authHeader = requestOptions.headers['Authorization'] as String?;
-
final String? ath;
-
if (authHeader != null && authHeader.startsWith('DPoP ')) {
-
ath = await options.sha256(authHeader.substring(5));
-
} else {
-
ath = null;
-
}
-
-
final uri = requestOptions.uri;
-
final origin =
-
'${uri.scheme}://${uri.host}${uri.hasPort ? ':${uri.port}' : ''}';
-
-
final htm = requestOptions.method;
-
final htu = _buildHtu(uri.toString());
-
-
// Try to get cached nonce for this origin
-
String? initNonce;
-
try {
-
initNonce = await options.nonces.get(origin);
-
} catch (_) {
-
// Ignore nonce retrieval errors
-
}
-
-
// Build and add DPoP proof
-
final initProof = await _buildProof(
-
options.key,
-
alg,
-
htm,
-
htu,
-
initNonce,
-
ath,
-
);
-
requestOptions.headers['DPoP'] = initProof;
-
-
handler.next(requestOptions);
-
} catch (e) {
-
handler.reject(
-
DioException(
-
requestOptions: requestOptions,
-
error: 'Failed to create DPoP proof: $e',
-
type: DioExceptionType.unknown,
-
),
-
);
-
}
-
},
-
onResponse: (response, handler) async {
-
try {
-
final uri = response.requestOptions.uri;
-
-
if (kDebugMode && uri.path.contains('/token')) {
-
print('๐ŸŸข DPoP interceptor onResponse triggered');
-
print(' URL: ${uri.path}');
-
print(' Status: ${response.statusCode}');
-
}
-
-
// Check for DPoP-Nonce header in response
-
final nextNonce = response.headers.value('dpop-nonce');
-
-
if (nextNonce != null) {
-
// Extract origin from request
-
final origin =
-
'${uri.scheme}://${uri.host}${uri.hasPort ? ':${uri.port}' : ''}';
-
-
// Store the fresh nonce for future requests
-
try {
-
await options.nonces.set(origin, nextNonce);
-
if (kDebugMode && uri.path.contains('/token')) {
-
print(' Cached nonce: ${nextNonce.substring(0, 20)}...');
-
}
-
} catch (_) {
-
// Ignore nonce storage errors
-
}
-
} else if (kDebugMode && uri.path.contains('/token')) {
-
print(' No nonce in response');
-
}
-
-
// Check for nonce errors in successful responses (when validateStatus: true)
-
// This handles the case where Dio returns 401 as a successful response
-
if (nextNonce != null &&
-
await _isUseDpopNonceError(response, options.isAuthServer)) {
-
final isTokenEndpoint =
-
uri.path.contains('/token') || uri.path.endsWith('/token');
-
-
if (kDebugMode) {
-
print(
-
'โš ๏ธ DPoP nonce error in response (status ${response.statusCode})',
-
);
-
print(' Is token endpoint: $isTokenEndpoint');
-
}
-
-
if (isTokenEndpoint) {
-
// Don't retry token endpoint - just pass through with nonce cached
-
if (kDebugMode) {
-
print(
-
' NOT retrying token endpoint (nonce cached for next attempt)',
-
);
-
}
-
handler.next(response);
-
return;
-
}
-
-
// For non-token endpoints, retry is safe
-
if (kDebugMode) {
-
print('๐Ÿ”„ Retrying request with fresh nonce');
-
}
-
-
try {
-
final authHeader =
-
response.requestOptions.headers['Authorization'] as String?;
-
final String? ath;
-
if (authHeader != null && authHeader.startsWith('DPoP ')) {
-
ath = await options.sha256(authHeader.substring(5));
-
} else {
-
ath = null;
-
}
-
-
final htm = response.requestOptions.method;
-
final htu = _buildHtu(uri.toString());
-
-
final nextProof = await _buildProof(
-
options.key,
-
alg,
-
htm,
-
htu,
-
nextNonce,
-
ath,
-
);
-
-
// Clone request options and update DPoP header
-
// Note: We preserve validateStatus to match original request behavior
-
final retryOptions = Options(
-
method: response.requestOptions.method,
-
headers: {...response.requestOptions.headers, 'DPoP': nextProof},
-
validateStatus: response.requestOptions.validateStatus,
-
);
-
-
// DESIGN NOTE: We create a fresh Dio instance for retry to avoid
-
// re-triggering this interceptor (which would cause infinite loops).
-
// This means base options (timeouts, etc.) are not preserved, but
-
// this is acceptable for DPoP nonce retry scenarios which should be fast.
-
// If this becomes an issue, we could inject a Dio factory function.
-
final dio = Dio();
-
final retryResponse = await dio.requestUri(
-
uri,
-
options: retryOptions,
-
data: response.requestOptions.data,
-
);
-
-
handler.resolve(retryResponse);
-
return;
-
} catch (retryError) {
-
if (kDebugMode) {
-
print('โŒ Retry failed: $retryError');
-
}
-
// If retry fails, return the original response
-
handler.next(response);
-
return;
-
}
-
}
-
-
handler.next(response);
-
} catch (e) {
-
handler.reject(
-
DioException(
-
requestOptions: response.requestOptions,
-
response: response,
-
error: 'Failed to process DPoP nonce: $e',
-
type: DioExceptionType.unknown,
-
),
-
);
-
}
-
},
-
onError: (error, handler) async {
-
final response = error.response;
-
if (response == null) {
-
handler.next(error);
-
return;
-
}
-
-
final uri = response.requestOptions.uri;
-
-
if (kDebugMode && uri.path.contains('/token')) {
-
print('๐Ÿ”ด DPoP interceptor onError triggered');
-
print(' URL: ${uri.path}');
-
print(' Status: ${response.statusCode}');
-
print(
-
' Has validateStatus: ${response.requestOptions.validateStatus != null}',
-
);
-
}
-
-
// Check for DPoP-Nonce in error response
-
final nextNonce = response.headers.value('dpop-nonce');
-
-
if (nextNonce != null) {
-
// Extract origin
-
final origin =
-
'${uri.scheme}://${uri.host}${uri.hasPort ? ':${uri.port}' : ''}';
-
-
// Store the fresh nonce for future requests
-
try {
-
await options.nonces.set(origin, nextNonce);
-
if (kDebugMode && uri.path.contains('/token')) {
-
print(' Cached nonce: ${nextNonce.substring(0, 20)}...');
-
}
-
} catch (_) {
-
// Ignore nonce storage errors
-
}
-
-
// Check if this is a "use_dpop_nonce" error
-
final isNonceError = await _isUseDpopNonceError(
-
response,
-
options.isAuthServer,
-
);
-
-
if (kDebugMode && uri.path.contains('/token')) {
-
print(' Is use_dpop_nonce error: $isNonceError');
-
}
-
-
if (isNonceError) {
-
// IMPORTANT: Do NOT retry for token endpoint!
-
// Retrying the token exchange can consume the authorization code,
-
// causing "Invalid code" errors on the retry.
-
//
-
// Instead, we rely on pre-fetching the nonce before critical operations
-
// (like authorization code exchange) to ensure we have a valid nonce
-
// from the start.
-
//
-
// We still cache the nonce for future requests, but we don't retry
-
// this particular request.
-
final isTokenEndpoint =
-
uri.path.contains('/token') || uri.path.endsWith('/token');
-
-
if (kDebugMode && isTokenEndpoint) {
-
print('โš ๏ธ DPoP nonce error on token endpoint - NOT retrying');
-
print(' Cached fresh nonce for future requests');
-
}
-
-
if (isTokenEndpoint) {
-
// Don't retry - just pass through the error with the nonce cached
-
handler.next(error);
-
return;
-
}
-
-
// For non-token endpoints, retry is safe
-
if (kDebugMode) {
-
print('๐Ÿ”„ DPoP retry for non-token endpoint: ${uri.path}');
-
}
-
-
try {
-
final authHeader =
-
response.requestOptions.headers['Authorization'] as String?;
-
final String? ath;
-
if (authHeader != null && authHeader.startsWith('DPoP ')) {
-
ath = await options.sha256(authHeader.substring(5));
-
} else {
-
ath = null;
-
}
-
-
final htm = response.requestOptions.method;
-
final htu = _buildHtu(uri.toString());
-
-
final nextProof = await _buildProof(
-
options.key,
-
alg,
-
htm,
-
htu,
-
nextNonce,
-
ath,
-
);
-
-
// Clone request options and update DPoP header
-
// Note: We preserve validateStatus to match original request behavior
-
final retryOptions = Options(
-
method: response.requestOptions.method,
-
headers: {...response.requestOptions.headers, 'DPoP': nextProof},
-
validateStatus: response.requestOptions.validateStatus,
-
);
-
-
// DESIGN NOTE: We create a fresh Dio instance for retry to avoid
-
// re-triggering this interceptor (which would cause infinite loops).
-
// This means base options (timeouts, etc.) are not preserved, but
-
// this is acceptable for DPoP nonce retry scenarios which should be fast.
-
// If this becomes an issue, we could inject a Dio factory function.
-
final dio = Dio();
-
final retryResponse = await dio.requestUri(
-
uri,
-
options: retryOptions,
-
data: response.requestOptions.data,
-
);
-
-
handler.resolve(retryResponse);
-
return;
-
} catch (retryError) {
-
// If retry fails, return the retry error
-
if (retryError is DioException) {
-
handler.next(retryError);
-
} else {
-
handler.next(
-
DioException(
-
requestOptions: response.requestOptions,
-
error: retryError,
-
type: DioExceptionType.unknown,
-
),
-
);
-
}
-
return;
-
}
-
}
-
}
-
-
if (kDebugMode && uri.path.contains('/token')) {
-
print('๐Ÿ”ด DPoP interceptor passing error through (no retry)');
-
}
-
-
handler.next(error);
-
},
-
);
-
}
-
-
/// Strips query string and fragment from URL.
-
///
-
/// Per RFC 9449, the htu (HTTP URI) claim must not include query or fragment.
-
///
-
/// See: https://www.rfc-editor.org/rfc/rfc9449.html#section-4.2-4.6
-
String _buildHtu(String url) {
-
final fragmentIndex = url.indexOf('#');
-
final queryIndex = url.indexOf('?');
-
-
final int end;
-
if (fragmentIndex == -1) {
-
end = queryIndex;
-
} else if (queryIndex == -1) {
-
end = fragmentIndex;
-
} else {
-
end = fragmentIndex < queryIndex ? fragmentIndex : queryIndex;
-
}
-
-
return end == -1 ? url : url.substring(0, end);
-
}
-
-
/// Builds a DPoP proof JWT.
-
///
-
/// The proof is a JWT with:
-
/// - Header: typ="dpop+jwt", alg, jwk (public key)
-
/// - Payload: iat, jti, htm, htu, nonce?, ath?
-
///
-
/// See: https://datatracker.ietf.org/doc/html/rfc9449#section-4.2
-
Future<String> _buildProof(
-
Key key,
-
String alg,
-
String htm,
-
String htu,
-
String? nonce,
-
String? ath,
-
) async {
-
final jwk = key.bareJwk;
-
if (jwk == null) {
-
throw StateError('Only asymmetric keys can be used for DPoP proofs');
-
}
-
-
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
-
-
// Create header
-
final header = {'alg': alg, 'typ': 'dpop+jwt', 'jwk': jwk};
-
-
// Create payload
-
final payload = {
-
'iat': now,
-
// Random jti to prevent replay attacks
-
// Any collision will cause server rejection, which is acceptable
-
'jti': DateTime.now().microsecondsSinceEpoch.toString(),
-
'htm': htm,
-
'htu': htu,
-
if (nonce != null) 'nonce': nonce,
-
if (ath != null) 'ath': ath,
-
};
-
-
if (kDebugMode && htu.contains('/token')) {
-
print('๐Ÿ” Creating DPoP proof for token request:');
-
print(' htm: $htm');
-
print(' htu: $htu');
-
print(' nonce: ${nonce ?? "none"}');
-
print(' ath: ${ath ?? "none"}');
-
print(' jwk keys: ${jwk?.keys.toList()}');
-
}
-
-
final jwt = await key.createJwt(header, payload);
-
-
if (kDebugMode && htu.contains('/token')) {
-
print(' โœ… DPoP proof created: ${jwt.substring(0, 50)}...');
-
}
-
-
return jwt;
-
}
-
-
/// Checks if a response indicates a "use_dpop_nonce" error.
-
///
-
/// There are multiple error formats depending on server implementation:
-
///
-
/// 1. Resource Server (RFC 6750): 401 with WWW-Authenticate header
-
/// WWW-Authenticate: DPoP error="use_dpop_nonce"
-
///
-
/// 2. Authorization Server: 400 with JSON body
-
/// {"error": "use_dpop_nonce"}
-
///
-
/// 3. Resource Server (JSON variant): 401 with JSON body
-
/// {"error": "use_dpop_nonce"}
-
///
-
/// See:
-
/// - https://datatracker.ietf.org/doc/html/rfc9449#name-resource-server-provided-no
-
/// - https://datatracker.ietf.org/doc/html/rfc9449#name-authorization-server-provid
-
Future<bool> _isUseDpopNonceError(Response response, bool? isAuthServer) async {
-
// Check WWW-Authenticate header format (401 + header)
-
if (response.statusCode == 401) {
-
final wwwAuth = response.headers.value('www-authenticate');
-
if (wwwAuth != null && wwwAuth.startsWith('DPoP')) {
-
if (wwwAuth.contains('error="use_dpop_nonce"')) {
-
return true;
-
}
-
}
-
}
-
-
// Check JSON body format (400 or 401 + JSON)
-
// Some servers use 401 + JSON instead of WWW-Authenticate header
-
if (response.statusCode == 400 || response.statusCode == 401) {
-
try {
-
final data = response.data;
-
if (data is Map<String, dynamic>) {
-
return data['error'] == 'use_dpop_nonce';
-
} else if (data is String) {
-
// Try to parse as JSON
-
final json = jsonDecode(data);
-
if (json is Map<String, dynamic>) {
-
return json['error'] == 'use_dpop_nonce';
-
}
-
}
-
} catch (_) {
-
// Invalid JSON or response too large, not a use_dpop_nonce error
-
return false;
-
}
-
}
-
-
return false;
-
}
-
-
/// Negotiates the algorithm to use for DPoP proofs.
-
///
-
/// If supportedAlgs is provided, uses the first algorithm that the key supports.
-
/// Otherwise, uses the key's first algorithm.
-
///
-
/// Throws if the key doesn't support any of the server's algorithms.
-
String _negotiateAlg(Key key, List<String>? supportedAlgs) {
-
if (supportedAlgs != null) {
-
// Use order of supportedAlgs as preference
-
for (final alg in supportedAlgs) {
-
if (key.algorithms.contains(alg)) {
-
return alg;
-
}
-
}
-
throw StateError(
-
'Key does not match any algorithm supported by the server. '
-
'Key supports: ${key.algorithms}, server supports: $supportedAlgs',
-
);
-
}
-
-
// No server preference, use key's first algorithm
-
if (key.algorithms.isEmpty) {
-
throw StateError('Key does not support any algorithms');
-
}
-
-
return key.algorithms.first;
-
}
-14
packages/atproto_oauth_flutter/lib/src/errors/auth_method_unsatisfiable_error.dart
···
-
/// Exception thrown when the requested authentication method cannot be satisfied.
-
class AuthMethodUnsatisfiableError implements Exception {
-
final String? message;
-
-
AuthMethodUnsatisfiableError([this.message]);
-
-
@override
-
String toString() {
-
if (message != null) {
-
return 'AuthMethodUnsatisfiableError: $message';
-
}
-
return 'AuthMethodUnsatisfiableError';
-
}
-
}
-10
packages/atproto_oauth_flutter/lib/src/errors/errors.dart
···
-
/// OAuth error types for the atproto_oauth_flutter package.
-
library;
-
-
export 'auth_method_unsatisfiable_error.dart';
-
export 'oauth_callback_error.dart';
-
export 'oauth_resolver_error.dart';
-
export 'oauth_response_error.dart';
-
export 'token_invalid_error.dart';
-
export 'token_refresh_error.dart';
-
export 'token_revoked_error.dart';
-51
packages/atproto_oauth_flutter/lib/src/errors/oauth_callback_error.dart
···
-
/// Error class for OAuth callback failures.
-
///
-
/// This error is thrown when an OAuth authorization callback contains
-
/// error parameters or fails to parse correctly.
-
///
-
/// See: https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1
-
class OAuthCallbackError implements Exception {
-
/// The URL parameters from the callback
-
final Map<String, String> params;
-
-
/// The state parameter from the callback (if present)
-
final String? state;
-
-
/// The error message
-
final String message;
-
-
/// Optional underlying cause
-
final Object? cause;
-
-
/// Creates an OAuth callback error from parameters.
-
///
-
/// The [params] should contain the parsed query parameters from the callback URL.
-
/// The [message] defaults to the error_description from params, or a generic message.
-
OAuthCallbackError(this.params, {String? message, this.state, this.cause})
-
: message =
-
message ?? params['error_description'] ?? 'OAuth callback error';
-
-
/// Creates an OAuthCallbackError from another error.
-
///
-
/// If [err] is already an OAuthCallbackError, returns it unchanged.
-
/// Otherwise, wraps the error with the given params and state.
-
static OAuthCallbackError from(
-
Object err,
-
Map<String, String> params, [
-
String? state,
-
]) {
-
if (err is OAuthCallbackError) return err;
-
final message = err is Exception ? err.toString() : null;
-
return OAuthCallbackError(
-
params,
-
message: message,
-
state: state,
-
cause: err,
-
);
-
}
-
-
@override
-
String toString() {
-
return 'OAuthCallbackError: $message';
-
}
-
}
-47
packages/atproto_oauth_flutter/lib/src/errors/oauth_resolver_error.dart
···
-
/// Error class for OAuth resolution failures.
-
///
-
/// This error is thrown when OAuth metadata resolution fails, including:
-
/// - Authorization server metadata discovery
-
/// - Protected resource metadata discovery
-
/// - Identity resolution (handle โ†’ DID โ†’ PDS)
-
class OAuthResolverError implements Exception {
-
/// The error message
-
final String message;
-
-
/// Optional underlying cause
-
final Object? cause;
-
-
/// Creates an OAuth resolver error.
-
OAuthResolverError(this.message, {this.cause});
-
-
/// Creates an OAuthResolverError from another error.
-
///
-
/// If [cause] is already an OAuthResolverError, returns it unchanged.
-
/// Otherwise, wraps the error with an appropriate message.
-
///
-
/// For validation errors, extracts the first error details.
-
static OAuthResolverError from(Object cause, [String? message]) {
-
if (cause is OAuthResolverError) return cause;
-
-
String? validationReason;
-
-
// Check if it's a validation error (would be FormatException or similar in Dart)
-
if (cause is FormatException) {
-
validationReason = cause.message;
-
}
-
-
final fullMessage =
-
(message ?? 'Unable to resolve OAuth metadata') +
-
(validationReason != null ? ' ($validationReason)' : '');
-
-
return OAuthResolverError(fullMessage, cause: cause);
-
}
-
-
@override
-
String toString() {
-
if (cause != null) {
-
return 'OAuthResolverError: $message (caused by: $cause)';
-
}
-
return 'OAuthResolverError: $message';
-
}
-
}
-62
packages/atproto_oauth_flutter/lib/src/errors/oauth_response_error.dart
···
-
import 'package:dio/dio.dart';
-
-
import '../util.dart';
-
-
/// Error class for OAuth protocol errors returned by the server.
-
///
-
/// OAuth servers return errors as JSON with standard fields:
-
/// - error: The error code (required)
-
/// - error_description: Human-readable description (optional)
-
/// - error_uri: URI with more information (optional)
-
///
-
/// See: https://datatracker.ietf.org/doc/html/rfc6749#section-5.2
-
class OAuthResponseError implements Exception {
-
/// The HTTP response that contained the error
-
final Response response;
-
-
/// The parsed response body (usually JSON)
-
final dynamic payload;
-
-
/// The OAuth error code (e.g., "invalid_request", "invalid_grant")
-
final String? error;
-
-
/// The human-readable error description
-
final String? errorDescription;
-
-
/// Creates an OAuth response error from a Dio response.
-
///
-
/// Automatically extracts the error and error_description fields
-
/// from the response payload if it's a JSON object.
-
OAuthResponseError(this.response, this.payload)
-
: error = _extractError(payload),
-
errorDescription = _extractErrorDescription(payload);
-
-
/// HTTP status code from the response
-
int get status => response.statusCode ?? 0;
-
-
/// HTTP headers from the response
-
Headers get headers => response.headers;
-
-
/// Extracts the error code from the payload
-
static String? _extractError(dynamic payload) {
-
if (payload is Map<String, dynamic>) {
-
return ifString(payload['error']);
-
}
-
return null;
-
}
-
-
/// Extracts the error description from the payload
-
static String? _extractErrorDescription(dynamic payload) {
-
if (payload is Map<String, dynamic>) {
-
return ifString(payload['error_description']);
-
}
-
return null;
-
}
-
-
@override
-
String toString() {
-
final errorCode = error ?? 'unknown';
-
final description = errorDescription != null ? ': $errorDescription' : '';
-
return 'OAuth "$errorCode" error$description';
-
}
-
}
-22
packages/atproto_oauth_flutter/lib/src/errors/token_invalid_error.dart
···
-
/// Exception thrown when a token is invalid.
-
class TokenInvalidError implements Exception {
-
/// Subject identifier for the invalid token
-
final String sub;
-
-
/// Error message
-
final String message;
-
-
/// Optional cause of the error
-
final Object? cause;
-
-
TokenInvalidError(this.sub, {String? message, this.cause})
-
: message = message ?? 'The session for "$sub" is invalid';
-
-
@override
-
String toString() {
-
if (cause != null) {
-
return 'TokenInvalidError: $message (caused by: $cause)';
-
}
-
return 'TokenInvalidError: $message';
-
}
-
}
-21
packages/atproto_oauth_flutter/lib/src/errors/token_refresh_error.dart
···
-
/// Exception thrown when a token refresh operation fails.
-
class TokenRefreshError implements Exception {
-
/// Subject identifier for the token that failed to refresh
-
final String sub;
-
-
/// Error message
-
final String message;
-
-
/// Optional cause of the error
-
final Object? cause;
-
-
TokenRefreshError(this.sub, this.message, {this.cause});
-
-
@override
-
String toString() {
-
if (cause != null) {
-
return 'TokenRefreshError: $message (caused by: $cause)';
-
}
-
return 'TokenRefreshError: $message';
-
}
-
}
-22
packages/atproto_oauth_flutter/lib/src/errors/token_revoked_error.dart
···
-
/// Exception thrown when a token has been successfully revoked.
-
class TokenRevokedError implements Exception {
-
/// Subject identifier for the revoked token
-
final String sub;
-
-
/// Error message
-
final String message;
-
-
/// Optional cause of the error
-
final Object? cause;
-
-
TokenRevokedError(this.sub, {String? message, this.cause})
-
: message = message ?? 'The session for "$sub" was successfully revoked';
-
-
@override
-
String toString() {
-
if (cause != null) {
-
return 'TokenRevokedError: $message (caused by: $cause)';
-
}
-
return 'TokenRevokedError: $message';
-
}
-
}
-263
packages/atproto_oauth_flutter/lib/src/identity/README.md
···
-
# atProto Identity Resolution Layer
-
-
## Overview
-
-
This module implements the **critical identity resolution functionality** for atProto decentralization. It resolves atProto handles and DIDs to discover where user data is actually stored (their Personal Data Server).
-
-
## Why This Matters
-
-
**This is the most important code for decentralization in atProto.**
-
-
Without this layer:
-
- Apps hardcode `bsky.social` as the only server
-
- Users can't use custom domains
-
- Self-hosting is impossible
-
- atProto becomes centralized
-
-
With this layer:
-
- โœ… Users host data on any PDS they choose
-
- โœ… Custom domain handles work (e.g., `alice.example.com`)
-
- โœ… Identity is portable (change PDS without losing DID)
-
- โœ… True decentralization is achieved
-
-
## Architecture
-
-
### Resolution Flow
-
-
```
-
Handle/DID Input
-
โ†“
-
Is it a DID? โ”€โ”€Yesโ”€โ”€โ†’ DID Resolution
-
โ†“ โ†“
-
No DID Document
-
โ†“ โ†“
-
Handle Resolution Extract Handle
-
โ†“ โ†“
-
DID Validate Handle โ†โ†’ DID
-
โ†“ โ†“
-
DID Resolution Return IdentityInfo
-
โ†“
-
DID Document
-
โ†“
-
Validate Handle in Doc
-
โ†“
-
Extract PDS URL
-
โ†“
-
Return IdentityInfo
-
```
-
-
### Key Components
-
-
#### 1. IdentityResolver
-
Main interface for resolving identities. Use `AtprotoIdentityResolver` for the standard implementation.
-
-
```dart
-
final resolver = AtprotoIdentityResolver.withDefaults(
-
handleResolverUrl: 'https://bsky.social',
-
);
-
-
// Resolve to PDS URL (most common use case)
-
final pdsUrl = await resolver.resolveToPds('alice.example.com');
-
-
// Get full identity info
-
final info = await resolver.resolve('alice.example.com');
-
print('DID: ${info.did}');
-
print('Handle: ${info.handle}');
-
print('PDS: ${info.pdsUrl}');
-
```
-
-
#### 2. HandleResolver
-
Resolves atProto handles (e.g., `alice.bsky.social`) to DIDs using XRPC.
-
-
**Resolution Methods:**
-
- XRPC: Uses `com.atproto.identity.resolveHandle` endpoint
-
- DNS TXT record: Checks `_atproto.{handle}` (not implemented yet)
-
- .well-known: Checks `https://{handle}/.well-known/atproto-did` (not implemented yet)
-
-
Current implementation uses XRPC, which works for all handles.
-
-
#### 3. DidResolver
-
Resolves DIDs to DID documents.
-
-
**Supported Methods:**
-
- `did:plc`: Queries PLC directory (https://plc.directory)
-
- `did:web`: Fetches from HTTPS URLs
-
-
#### 4. DidDocument
-
Represents a W3C DID document with atProto-specific helpers:
-
- `extractPdsUrl()`: Gets the PDS endpoint
-
- `extractNormalizedHandle()`: Gets the validated handle
-
-
### Bi-directional Resolution
-
-
For security, we enforce **bi-directional resolution**:
-
-
1. Handle โ†’ DID resolution must succeed
-
2. DID document must contain the original handle
-
3. Both directions must agree
-
-
This prevents:
-
- Handle hijacking
-
- DID spoofing
-
- MITM attacks
-
-
### Caching
-
-
Built-in caching with configurable TTLs:
-
- **Handles**: 1 hour default (handles can change)
-
- **DIDs**: 24 hours default (DID docs are more stable)
-
-
Caching is automatic but can be bypassed with `noCache: true`.
-
-
## File Structure
-
-
```
-
identity/
-
โ”œโ”€โ”€ constants.dart # atProto constants
-
โ”œโ”€โ”€ did_document.dart # DID document representation
-
โ”œโ”€โ”€ did_helpers.dart # DID validation utilities
-
โ”œโ”€โ”€ did_resolver.dart # DID โ†’ DID document resolution
-
โ”œโ”€โ”€ handle_helpers.dart # Handle validation utilities
-
โ”œโ”€โ”€ handle_resolver.dart # Handle โ†’ DID resolution
-
โ”œโ”€โ”€ identity_resolver.dart # Main resolver (orchestrates everything)
-
โ”œโ”€โ”€ identity_resolver_error.dart # Error types
-
โ”œโ”€โ”€ identity.dart # Public exports
-
โ””โ”€โ”€ README.md # This file
-
```
-
-
## Usage Examples
-
-
### Basic Resolution
-
-
```dart
-
import 'package:atproto_oauth_flutter/src/identity/identity.dart';
-
-
final resolver = AtprotoIdentityResolver.withDefaults(
-
handleResolverUrl: 'https://bsky.social',
-
);
-
-
// Simple PDS lookup
-
final pdsUrl = await resolver.resolveToPds('alice.bsky.social');
-
print('PDS: $pdsUrl');
-
```
-
-
### Custom Configuration
-
-
```dart
-
// With custom caching and PLC directory
-
final resolver = AtprotoIdentityResolver.withDefaults(
-
handleResolverUrl: 'https://bsky.social',
-
plcDirectoryUrl: 'https://plc.directory/',
-
didCache: InMemoryDidCache(ttl: Duration(hours: 12)),
-
handleCache: InMemoryHandleCache(ttl: Duration(minutes: 30)),
-
);
-
```
-
-
### Manual Component Construction
-
-
```dart
-
// Build your own resolver with custom components
-
final dio = Dio();
-
-
final didResolver = CachedDidResolver(
-
AtprotoDidResolver(dio: dio),
-
);
-
-
final handleResolver = CachedHandleResolver(
-
XrpcHandleResolver('https://bsky.social', dio: dio),
-
);
-
-
final resolver = AtprotoIdentityResolver(
-
didResolver: didResolver,
-
handleResolver: handleResolver,
-
);
-
```
-
-
### Error Handling
-
-
```dart
-
try {
-
final info = await resolver.resolve('invalid-handle');
-
} on InvalidHandleError catch (e) {
-
print('Invalid handle format: $e');
-
} on HandleResolverError catch (e) {
-
print('Handle resolution failed: $e');
-
} on DidResolverError catch (e) {
-
print('DID resolution failed: $e');
-
} on IdentityResolverError catch (e) {
-
print('Identity resolution failed: $e');
-
}
-
```
-
-
## Implementation Notes
-
-
### Ported from TypeScript
-
-
This implementation is a 1:1 port from the official atProto TypeScript packages:
-
- `@atproto-labs/identity-resolver`
-
- `@atproto-labs/did-resolver`
-
- `@atproto-labs/handle-resolver`
-
-
Source: `/home/bretton/Code/atproto/packages/oauth/oauth-client/src/identity-resolver.ts`
-
-
### Differences from TypeScript
-
-
1. **No DNS Resolution**: Dart doesn't have built-in DNS TXT lookups. We use XRPC only.
-
2. **Simplified Caching**: In-memory only (TypeScript has more cache backends).
-
3. **Dio instead of Fetch**: Using Dio HTTP client instead of global fetch.
-
4. **Explicit Types**: Dart's type system is more explicit than TypeScript's.
-
-
### Future Improvements
-
-
- [ ] Add DNS-over-HTTPS for handle resolution
-
- [ ] Implement .well-known handle resolution
-
- [ ] Add persistent cache backends (SQLite, Hive)
-
- [ ] Support custom DID methods beyond plc/web
-
- [ ] Add metrics and observability
-
- [ ] Implement resolver timeouts and retries
-
-
## Testing
-
-
Test the implementation with real handles:
-
-
```dart
-
// Test custom PDS
-
final pds1 = await resolver.resolveToPds('bretton.dev');
-
assert(pds1.contains('pds.bretton.dev'));
-
-
// Test Bluesky user
-
final pds2 = await resolver.resolveToPds('pfrazee.com');
-
print('Paul Frazee PDS: $pds2');
-
-
// Test from DID
-
final info = await resolver.resolveFromDid('did:plc:ragtjsm2j2vknwkz3zp4oxrd');
-
assert(info.handle == 'pfrazee.com');
-
```
-
-
## Security Considerations
-
-
1. **Bi-directional Validation**: Always enforced to prevent spoofing
-
2. **HTTPS Only**: All HTTP requests use HTTPS (except localhost for testing)
-
3. **No Redirects**: HTTP redirects are rejected to prevent attacks
-
4. **Input Validation**: All handles and DIDs are validated before use
-
5. **Cache Poisoning**: TTLs prevent stale data, noCache option available
-
-
## Performance
-
-
Typical resolution times (with cold cache):
-
- Handle โ†’ PDS: ~200-500ms (1 handle lookup + 1 DID fetch)
-
- DID โ†’ PDS: ~100-200ms (1 DID fetch only)
-
- Cached resolution: <1ms (in-memory lookup)
-
-
For production apps:
-
- Enable caching (default)
-
- Use connection pooling (Dio does this)
-
- Consider warming cache for known users
-
- Monitor resolver errors and timeouts
-
-
## References
-
-
- [atProto DID Spec](https://atproto.com/specs/did)
-
- [atProto Handle Spec](https://atproto.com/specs/handle)
-
- [W3C DID Core](https://www.w3.org/TR/did-core/)
-
- [PLC Directory](https://plc.directory/)
-29
packages/atproto_oauth_flutter/lib/src/identity/constants.dart
···
-
/// Constants used in atProto identity resolution.
-
library;
-
-
/// Placeholder handle used when handle is invalid or doesn't match DID.
-
const String handleInvalid = 'handle.invalid';
-
-
/// DID prefix for all decentralized identifiers.
-
const String didPrefix = 'did:';
-
-
/// DID PLC (Placeholder) prefix.
-
const String didPlcPrefix = 'did:plc:';
-
-
/// DID Web prefix.
-
const String didWebPrefix = 'did:web:';
-
-
/// Length of a complete did:plc identifier (including prefix).
-
const int didPlcLength = 32;
-
-
/// Default PLC directory URL for resolving did:plc identifiers.
-
const String defaultPlcDirectoryUrl = 'https://plc.directory/';
-
-
/// Maximum length for a DID (per spec).
-
const int maxDidLength = 2048;
-
-
/// atProto service type in DID documents.
-
const String atprotoServiceType = 'AtprotoPersonalDataServer';
-
-
/// atProto service ID prefix in DID documents.
-
const String atprotoServiceId = '#atproto_pds';
-156
packages/atproto_oauth_flutter/lib/src/identity/did_document.dart
···
-
import 'constants.dart';
-
import 'handle_helpers.dart';
-
-
/// Represents a DID document as defined by W3C DID Core spec.
-
///
-
/// This is a simplified version focused on atProto needs.
-
/// See: https://www.w3.org/TR/did-core/
-
class DidDocument {
-
/// The DID subject (the DID itself)
-
final String id;
-
-
/// Alternative identifiers (used for atProto handles: at://handle)
-
final List<String>? alsoKnownAs;
-
-
/// Service endpoints (used to find PDS URL)
-
final List<DidService>? service;
-
-
/// Verification methods for authentication
-
final List<dynamic>? verificationMethod;
-
-
/// Authentication methods
-
final List<dynamic>? authentication;
-
-
/// Optional controller DIDs
-
final dynamic controller; // Can be String or List<String>
-
-
/// The @context field
-
final dynamic context;
-
-
const DidDocument({
-
required this.id,
-
this.alsoKnownAs,
-
this.service,
-
this.verificationMethod,
-
this.authentication,
-
this.controller,
-
this.context,
-
});
-
-
/// Parses a DID document from JSON.
-
factory DidDocument.fromJson(Map<String, dynamic> json) {
-
return DidDocument(
-
id: json['id'] as String,
-
alsoKnownAs:
-
(json['alsoKnownAs'] as List<dynamic>?)
-
?.map((e) => e as String)
-
.toList(),
-
service:
-
(json['service'] as List<dynamic>?)
-
?.map((e) => DidService.fromJson(e as Map<String, dynamic>))
-
.toList(),
-
verificationMethod: json['verificationMethod'] as List<dynamic>?,
-
authentication: json['authentication'] as List<dynamic>?,
-
controller: json['controller'],
-
context: json['@context'],
-
);
-
}
-
-
/// Converts the DID document to JSON.
-
Map<String, dynamic> toJson() {
-
final map = <String, dynamic>{'id': id};
-
-
if (context != null) map['@context'] = context;
-
if (alsoKnownAs != null) map['alsoKnownAs'] = alsoKnownAs;
-
if (service != null) {
-
map['service'] = service!.map((s) => s.toJson()).toList();
-
}
-
if (verificationMethod != null) {
-
map['verificationMethod'] = verificationMethod;
-
}
-
if (authentication != null) map['authentication'] = authentication;
-
if (controller != null) map['controller'] = controller;
-
-
return map;
-
}
-
-
/// Extracts the atProto PDS URL from the DID document.
-
///
-
/// Returns null if no PDS service is found.
-
String? extractPdsUrl() {
-
if (service == null) return null;
-
-
for (final s in service!) {
-
// Check for standard atproto_pds service
-
if (s.id == atprotoServiceId && s.type == atprotoServiceType) {
-
if (s.serviceEndpoint is String) {
-
return s.serviceEndpoint as String;
-
}
-
}
-
-
// Also check if type matches (some implementations may vary on id)
-
if (s.type == atprotoServiceType && s.serviceEndpoint is String) {
-
return s.serviceEndpoint as String;
-
}
-
}
-
-
return null;
-
}
-
-
/// Extracts the raw atProto handle from the DID document.
-
///
-
/// Returns null if no handle is found in alsoKnownAs.
-
String? extractAtprotoHandle() {
-
if (alsoKnownAs == null) return null;
-
-
for (final aka in alsoKnownAs!) {
-
if (aka.startsWith('at://')) {
-
// Strip off "at://" prefix
-
return aka.substring(5);
-
}
-
}
-
-
return null;
-
}
-
-
/// Extracts a validated, normalized atProto handle from the DID document.
-
///
-
/// Returns null if no valid handle is found.
-
String? extractNormalizedHandle() {
-
final handle = extractAtprotoHandle();
-
if (handle == null) return null;
-
return asNormalizedHandle(handle);
-
}
-
}
-
-
/// Represents a service endpoint in a DID document.
-
class DidService {
-
/// Service ID (e.g., "#atproto_pds")
-
final String id;
-
-
/// Service type (e.g., "AtprotoPersonalDataServer")
-
final String type;
-
-
/// Service endpoint URL
-
final dynamic serviceEndpoint; // Can be String, Map, or List
-
-
const DidService({
-
required this.id,
-
required this.type,
-
required this.serviceEndpoint,
-
});
-
-
/// Parses a service from JSON.
-
factory DidService.fromJson(Map<String, dynamic> json) {
-
return DidService(
-
id: json['id'] as String,
-
type: json['type'] as String,
-
serviceEndpoint: json['serviceEndpoint'],
-
);
-
}
-
-
/// Converts the service to JSON.
-
Map<String, dynamic> toJson() {
-
return {'id': id, 'type': type, 'serviceEndpoint': serviceEndpoint};
-
}
-
}
-251
packages/atproto_oauth_flutter/lib/src/identity/did_helpers.dart
···
-
import 'constants.dart';
-
import 'identity_resolver_error.dart';
-
-
/// Checks if a string is a valid DID.
-
///
-
/// A valid DID follows the format: did:method:method-specific-id
-
/// where method is lowercase alphanumeric and method-specific-id
-
/// contains only allowed characters.
-
bool isDid(String input) {
-
try {
-
assertDid(input);
-
return true;
-
} catch (e) {
-
if (e is IdentityResolverError) {
-
return false;
-
}
-
rethrow;
-
}
-
}
-
-
/// Asserts that a string is a valid DID, throwing if not.
-
void assertDid(String input) {
-
if (input.length > maxDidLength) {
-
throw InvalidDidError(input, 'DID is too long ($maxDidLength chars max)');
-
}
-
-
if (!input.startsWith(didPrefix)) {
-
throw InvalidDidError(input, 'DID requires "$didPrefix" prefix');
-
}
-
-
final methodEndIndex = input.indexOf(':', didPrefix.length);
-
if (methodEndIndex == -1) {
-
throw InvalidDidError(input, 'Missing colon after method name');
-
}
-
-
_assertDidMethod(input, didPrefix.length, methodEndIndex);
-
_assertDidMsid(input, methodEndIndex + 1, input.length);
-
}
-
-
/// Validates DID method name (lowercase alphanumeric).
-
void _assertDidMethod(String input, int start, int end) {
-
if (end == start) {
-
throw InvalidDidError(input, 'Empty method name');
-
}
-
-
for (int i = start; i < end; i++) {
-
final c = input.codeUnitAt(i);
-
if (!((c >= 0x61 && c <= 0x7a) || (c >= 0x30 && c <= 0x39))) {
-
// Not a-z or 0-9
-
throw InvalidDidError(
-
input,
-
'Invalid character at position $i in DID method name',
-
);
-
}
-
}
-
}
-
-
/// Validates DID method-specific identifier.
-
void _assertDidMsid(String input, int start, int end) {
-
if (end == start) {
-
throw InvalidDidError(input, 'DID method-specific id must not be empty');
-
}
-
-
for (int i = start; i < end; i++) {
-
final c = input.codeUnitAt(i);
-
-
// Check for frequent chars first (a-z, A-Z, 0-9, ., -, _)
-
if ((c >= 0x61 && c <= 0x7a) || // a-z
-
(c >= 0x41 && c <= 0x5a) || // A-Z
-
(c >= 0x30 && c <= 0x39) || // 0-9
-
c == 0x2e || // .
-
c == 0x2d || // -
-
c == 0x5f) {
-
// _
-
continue;
-
}
-
-
// ":"
-
if (c == 0x3a) {
-
if (i == end - 1) {
-
throw InvalidDidError(input, 'DID cannot end with ":"');
-
}
-
continue;
-
}
-
-
// pct-encoded: %HEXDIG HEXDIG
-
if (c == 0x25) {
-
// %
-
if (i + 2 >= end) {
-
throw InvalidDidError(
-
input,
-
'Incomplete pct-encoded character at position $i',
-
);
-
}
-
-
i++;
-
final c1 = input.codeUnitAt(i);
-
if (!((c1 >= 0x30 && c1 <= 0x39) || (c1 >= 0x41 && c1 <= 0x46))) {
-
// Not 0-9 or A-F
-
throw InvalidDidError(
-
input,
-
'Invalid pct-encoded character at position $i',
-
);
-
}
-
-
i++;
-
final c2 = input.codeUnitAt(i);
-
if (!((c2 >= 0x30 && c2 <= 0x39) || (c2 >= 0x41 && c2 <= 0x46))) {
-
// Not 0-9 or A-F
-
throw InvalidDidError(
-
input,
-
'Invalid pct-encoded character at position $i',
-
);
-
}
-
-
continue;
-
}
-
-
throw InvalidDidError(input, 'Disallowed character in DID at position $i');
-
}
-
}
-
-
/// Extracts the method name from a DID.
-
///
-
/// Example: extractDidMethod('did:plc:abc123') returns 'plc'
-
String extractDidMethod(String did) {
-
final methodEndIndex = did.indexOf(':', didPrefix.length);
-
return did.substring(didPrefix.length, methodEndIndex);
-
}
-
-
/// Checks if a string is a valid did:plc identifier.
-
bool isDidPlc(String input) {
-
if (input.length != didPlcLength) return false;
-
if (!input.startsWith(didPlcPrefix)) return false;
-
-
// Check that all characters after prefix are base32 [a-z2-7]
-
for (int i = didPlcPrefix.length; i < didPlcLength; i++) {
-
if (!_isBase32Char(input.codeUnitAt(i))) return false;
-
}
-
-
return true;
-
}
-
-
/// Checks if a string is a valid did:web identifier.
-
bool isDidWeb(String input) {
-
if (!input.startsWith(didWebPrefix)) return false;
-
if (input.length <= didWebPrefix.length) return false;
-
-
// Check if next char after prefix is ":"
-
if (input.codeUnitAt(didWebPrefix.length) == 0x3a) return false;
-
-
try {
-
_assertDidMsid(input, didWebPrefix.length, input.length);
-
return true;
-
} catch (e) {
-
return false;
-
}
-
}
-
-
/// Checks if a DID uses an atProto-blessed method (plc or web).
-
bool isAtprotoDid(String input) {
-
return isDidPlc(input) || isDidWeb(input);
-
}
-
-
/// Asserts that a string is a valid atProto DID (did:plc or did:web).
-
///
-
/// Throws [InvalidDidError] if the DID is not a valid atProto DID.
-
void assertAtprotoDid(String input) {
-
if (!isAtprotoDid(input)) {
-
throw InvalidDidError(
-
input,
-
'DID must use atProto-blessed method (did:plc or did:web)',
-
);
-
}
-
}
-
-
/// Asserts that a string is a valid did:plc identifier.
-
void assertDidPlc(String input) {
-
if (!input.startsWith(didPlcPrefix)) {
-
throw InvalidDidError(input, 'Invalid did:plc prefix');
-
}
-
-
if (input.length != didPlcLength) {
-
throw InvalidDidError(
-
input,
-
'did:plc must be $didPlcLength characters long',
-
);
-
}
-
-
for (int i = didPlcPrefix.length; i < didPlcLength; i++) {
-
if (!_isBase32Char(input.codeUnitAt(i))) {
-
throw InvalidDidError(input, 'Invalid character at position $i');
-
}
-
}
-
}
-
-
/// Asserts that a string is a valid did:web identifier.
-
void assertDidWeb(String input) {
-
if (!input.startsWith(didWebPrefix)) {
-
throw InvalidDidError(input, 'Invalid did:web prefix');
-
}
-
-
if (input.codeUnitAt(didWebPrefix.length) == 0x3a) {
-
throw InvalidDidError(input, 'did:web MSID must not start with a colon');
-
}
-
-
_assertDidMsid(input, didWebPrefix.length, input.length);
-
}
-
-
/// Checks if a character code is a base32 character [a-z2-7].
-
bool _isBase32Char(int c) =>
-
(c >= 0x61 && c <= 0x7a) || (c >= 0x32 && c <= 0x37);
-
-
/// Converts a did:web to an HTTPS URL.
-
///
-
/// Example:
-
/// - did:web:example.com -> https://example.com
-
/// - did:web:example.com:user:alice -> https://example.com/user/alice
-
/// - did:web:localhost%3A3000 -> http://localhost:3000
-
Uri didWebToUrl(String did) {
-
assertDidWeb(did);
-
-
final hostIdx = didWebPrefix.length;
-
final pathIdx = did.indexOf(':', hostIdx);
-
-
final hostEnc =
-
pathIdx == -1 ? did.substring(hostIdx) : did.substring(hostIdx, pathIdx);
-
final host = hostEnc.replaceAll('%3A', ':');
-
final path = pathIdx == -1 ? '' : did.substring(pathIdx).replaceAll(':', '/');
-
-
// Use http for localhost, https for everything else
-
final proto =
-
host.startsWith('localhost') &&
-
(host.length == 9 || host.codeUnitAt(9) == 0x3a) // ':'
-
? 'http'
-
: 'https';
-
-
return Uri.parse('$proto://$host$path');
-
}
-
-
/// Converts an HTTPS URL to a did:web identifier.
-
///
-
/// Example:
-
/// - https://example.com -> did:web:example.com
-
/// - https://example.com/user/alice -> did:web:example.com:user:alice
-
String urlToDidWeb(Uri url) {
-
final port = url.hasPort ? '%3A${url.port}' : '';
-
final path = url.path == '/' ? '' : url.path.replaceAll('/', ':');
-
-
return '$didWebPrefix${url.host}$port$path';
-
}
-257
packages/atproto_oauth_flutter/lib/src/identity/did_resolver.dart
···
-
import 'package:dio/dio.dart';
-
-
import 'constants.dart';
-
import 'did_document.dart';
-
import 'did_helpers.dart';
-
import 'identity_resolver_error.dart';
-
-
/// Options for DID resolution.
-
class ResolveDidOptions {
-
/// Whether to bypass cache
-
final bool noCache;
-
-
/// Cancellation token for the request
-
final CancelToken? cancelToken;
-
-
const ResolveDidOptions({this.noCache = false, this.cancelToken});
-
}
-
-
/// Interface for resolving DIDs to DID documents.
-
abstract class DidResolver {
-
/// Resolves a DID to its DID document.
-
///
-
/// Throws [DidResolverError] if resolution fails.
-
Future<DidDocument> resolve(String did, [ResolveDidOptions? options]);
-
}
-
-
/// DID resolver that supports both did:plc and did:web methods.
-
class AtprotoDidResolver implements DidResolver {
-
final DidPlcMethod _plcMethod;
-
final DidWebMethod _webMethod;
-
-
AtprotoDidResolver({String? plcDirectoryUrl, Dio? dio})
-
: _plcMethod = DidPlcMethod(plcDirectoryUrl: plcDirectoryUrl, dio: dio),
-
_webMethod = DidWebMethod(dio: dio);
-
-
@override
-
Future<DidDocument> resolve(String did, [ResolveDidOptions? options]) async {
-
if (isDidPlc(did)) {
-
return _plcMethod.resolve(did, options);
-
} else if (isDidWeb(did)) {
-
return _webMethod.resolve(did, options);
-
} else {
-
throw DidResolverError(
-
'Unsupported DID method: ${extractDidMethod(did)}',
-
);
-
}
-
}
-
}
-
-
/// Resolver for did:plc identifiers using the PLC directory.
-
class DidPlcMethod {
-
final Uri plcDirectoryUrl;
-
final Dio dio;
-
-
DidPlcMethod({String? plcDirectoryUrl, Dio? dio})
-
: plcDirectoryUrl = Uri.parse(plcDirectoryUrl ?? defaultPlcDirectoryUrl),
-
dio = dio ?? Dio();
-
-
Future<DidDocument> resolve(String did, [ResolveDidOptions? options]) async {
-
assertDidPlc(did);
-
-
final url = plcDirectoryUrl.resolve('/${Uri.encodeComponent(did)}');
-
-
try {
-
final response = await dio.getUri(
-
url,
-
options: Options(
-
headers: {
-
'Accept': 'application/did+ld+json,application/json',
-
if (options?.noCache ?? false) 'Cache-Control': 'no-cache',
-
},
-
followRedirects: false,
-
validateStatus: (status) => status == 200,
-
),
-
cancelToken: options?.cancelToken,
-
);
-
-
if (response.data is! Map<String, dynamic>) {
-
throw DidResolverError(
-
'Invalid response format from PLC directory for $did',
-
);
-
}
-
-
return DidDocument.fromJson(response.data as Map<String, dynamic>);
-
} on DioException catch (e) {
-
if (e.type == DioExceptionType.cancel) {
-
throw DidResolverError('DID resolution was cancelled');
-
}
-
-
if (e.response?.statusCode == 404) {
-
throw DidResolverError('DID not found: $did');
-
}
-
-
throw DidResolverError(
-
'Failed to resolve DID from PLC directory: ${e.message}',
-
e,
-
);
-
} catch (e) {
-
if (e is DidResolverError) rethrow;
-
-
throw DidResolverError('Unexpected error resolving DID: $e', e);
-
}
-
}
-
}
-
-
/// Resolver for did:web identifiers using HTTPS.
-
class DidWebMethod {
-
final Dio dio;
-
-
DidWebMethod({Dio? dio}) : dio = dio ?? Dio();
-
-
Future<DidDocument> resolve(String did, [ResolveDidOptions? options]) async {
-
assertDidWeb(did);
-
-
final baseUrl = didWebToUrl(did);
-
-
// Try /.well-known/did.json first, then /did.json
-
final urls = [
-
baseUrl.resolve('/.well-known/did.json'),
-
baseUrl.resolve('/did.json'),
-
];
-
-
DioException? lastError;
-
-
for (final url in urls) {
-
try {
-
final response = await dio.getUri(
-
url,
-
options: Options(
-
headers: {
-
'Accept': 'application/did+ld+json,application/json',
-
if (options?.noCache ?? false) 'Cache-Control': 'no-cache',
-
},
-
followRedirects: false,
-
validateStatus: (status) => status == 200,
-
),
-
cancelToken: options?.cancelToken,
-
);
-
-
if (response.data is! Map<String, dynamic>) {
-
throw DidResolverError(
-
'Invalid response format from did:web for $did',
-
);
-
}
-
-
final doc = DidDocument.fromJson(response.data as Map<String, dynamic>);
-
-
// Verify the DID in the document matches
-
if (doc.id != did) {
-
throw DidResolverError(
-
'DID mismatch: expected $did but got ${doc.id}',
-
);
-
}
-
-
return doc;
-
} on DioException catch (e) {
-
if (e.type == DioExceptionType.cancel) {
-
throw DidResolverError('DID resolution was cancelled');
-
}
-
-
// If not found, try the next URL
-
if (e.response?.statusCode == 404) {
-
lastError = e;
-
continue;
-
}
-
-
// Any other error, throw immediately
-
throw DidResolverError('Failed to resolve did:web: ${e.message}', e);
-
} catch (e) {
-
if (e is DidResolverError) rethrow;
-
-
throw DidResolverError('Unexpected error resolving did:web: $e', e);
-
}
-
}
-
-
// If we get here, all URLs failed
-
throw DidResolverError('DID document not found for $did', lastError);
-
}
-
}
-
-
/// Cached DID resolver that wraps another resolver with caching.
-
class CachedDidResolver implements DidResolver {
-
final DidResolver _resolver;
-
final DidCache _cache;
-
-
CachedDidResolver(this._resolver, [DidCache? cache])
-
: _cache = cache ?? InMemoryDidCache();
-
-
@override
-
Future<DidDocument> resolve(String did, [ResolveDidOptions? options]) async {
-
// Check cache first unless noCache is set
-
if (!(options?.noCache ?? false)) {
-
final cached = await _cache.get(did);
-
if (cached != null) {
-
return cached;
-
}
-
}
-
-
// Resolve and cache
-
final doc = await _resolver.resolve(did, options);
-
await _cache.set(did, doc);
-
-
return doc;
-
}
-
-
/// Clears the cache
-
Future<void> clearCache() => _cache.clear();
-
}
-
-
/// Interface for caching DID documents.
-
abstract class DidCache {
-
Future<DidDocument?> get(String did);
-
Future<void> set(String did, DidDocument document);
-
Future<void> clear();
-
}
-
-
/// Simple in-memory DID cache with expiration.
-
class InMemoryDidCache implements DidCache {
-
final Map<String, _CacheEntry> _cache = {};
-
final Duration _ttl;
-
-
InMemoryDidCache({Duration? ttl}) : _ttl = ttl ?? const Duration(hours: 24);
-
-
@override
-
Future<DidDocument?> get(String did) async {
-
final entry = _cache[did];
-
if (entry == null) return null;
-
-
// Check if expired
-
if (DateTime.now().isAfter(entry.expiresAt)) {
-
_cache.remove(did);
-
return null;
-
}
-
-
return entry.document;
-
}
-
-
@override
-
Future<void> set(String did, DidDocument document) async {
-
_cache[did] = _CacheEntry(
-
document: document,
-
expiresAt: DateTime.now().add(_ttl),
-
);
-
}
-
-
@override
-
Future<void> clear() async {
-
_cache.clear();
-
}
-
}
-
-
class _CacheEntry {
-
final DidDocument document;
-
final DateTime expiresAt;
-
-
_CacheEntry({required this.document, required this.expiresAt});
-
}
-35
packages/atproto_oauth_flutter/lib/src/identity/handle_helpers.dart
···
-
import 'identity_resolver_error.dart';
-
-
/// Normalizes a handle to lowercase.
-
String normalizeHandle(String handle) => handle.toLowerCase();
-
-
/// Checks if a handle is valid according to atProto spec.
-
///
-
/// A valid handle must:
-
/// - Be between 1 and 253 characters
-
/// - Match the pattern: subdomain.domain.tld
-
/// - Each label must start and end with alphanumeric
-
/// - Labels can contain hyphens but not at boundaries
-
bool isValidHandle(String handle) {
-
if (handle.isEmpty || handle.length >= 254) return false;
-
-
// Pattern: ([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?
-
final pattern = RegExp(
-
r'^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$',
-
);
-
-
return pattern.hasMatch(handle);
-
}
-
-
/// Returns a normalized handle if valid, null otherwise.
-
String? asNormalizedHandle(String input) {
-
final handle = normalizeHandle(input);
-
return isValidHandle(handle) ? handle : null;
-
}
-
-
/// Asserts that a handle is valid.
-
void assertValidHandle(String handle) {
-
if (!isValidHandle(handle)) {
-
throw InvalidHandleError(handle, 'Invalid handle format');
-
}
-
}
-202
packages/atproto_oauth_flutter/lib/src/identity/handle_resolver.dart
···
-
import 'package:dio/dio.dart';
-
-
import 'did_helpers.dart';
-
import 'identity_resolver_error.dart';
-
-
/// Options for handle resolution.
-
class ResolveHandleOptions {
-
/// Whether to bypass cache
-
final bool noCache;
-
-
/// Cancellation token for the request
-
final CancelToken? cancelToken;
-
-
const ResolveHandleOptions({this.noCache = false, this.cancelToken});
-
}
-
-
/// Interface for resolving atProto handles to DIDs.
-
abstract class HandleResolver {
-
/// Resolves an atProto handle to a DID.
-
///
-
/// Returns null if the handle doesn't resolve to a DID (but no error occurred).
-
/// Throws [HandleResolverError] if an unexpected error occurs during resolution.
-
Future<String?> resolve(String handle, [ResolveHandleOptions? options]);
-
}
-
-
/// XRPC-based handle resolver that uses com.atproto.identity.resolveHandle.
-
///
-
/// This resolver makes HTTP requests to an atProto XRPC service (typically
-
/// a PDS or entryway service) to resolve handles.
-
class XrpcHandleResolver implements HandleResolver {
-
/// The base URL of the XRPC service
-
final Uri serviceUrl;
-
-
/// HTTP client for making requests
-
final Dio dio;
-
-
XrpcHandleResolver(String serviceUrl, {Dio? dio})
-
: serviceUrl = Uri.parse(serviceUrl),
-
dio = dio ?? Dio();
-
-
@override
-
Future<String?> resolve(
-
String handle, [
-
ResolveHandleOptions? options,
-
]) async {
-
final url = serviceUrl.resolve('/xrpc/com.atproto.identity.resolveHandle');
-
final uri = url.replace(queryParameters: {'handle': handle});
-
-
try {
-
final response = await dio.getUri(
-
uri,
-
options: Options(
-
headers: {if (options?.noCache ?? false) 'Cache-Control': 'no-cache'},
-
validateStatus: (status) {
-
// Allow 400 and 200 status codes
-
return status == 200 || status == 400;
-
},
-
),
-
cancelToken: options?.cancelToken,
-
);
-
-
final data = response.data;
-
-
// Handle 400 Bad Request (expected for invalid/unresolvable handles)
-
if (response.statusCode == 400) {
-
if (data is Map<String, dynamic>) {
-
final error = data['error'] as String?;
-
final message = data['message'] as String?;
-
-
// Expected response for handle that doesn't exist
-
if (error == 'InvalidRequest' &&
-
message == 'Unable to resolve handle') {
-
return null;
-
}
-
}
-
-
throw HandleResolverError(
-
'Invalid response from resolveHandle method: ${response.data}',
-
);
-
}
-
-
// Handle successful response
-
if (response.statusCode == 200) {
-
if (data is! Map<String, dynamic>) {
-
throw HandleResolverError(
-
'Invalid response format from resolveHandle method',
-
);
-
}
-
-
final did = data['did'];
-
if (did is! String) {
-
throw HandleResolverError(
-
'Missing or invalid DID in resolveHandle response',
-
);
-
}
-
-
// Validate that it's a proper atProto DID
-
if (!isAtprotoDid(did)) {
-
throw HandleResolverError(
-
'Invalid DID returned from resolveHandle method: $did',
-
);
-
}
-
-
return did;
-
}
-
-
throw HandleResolverError(
-
'Unexpected status code from resolveHandle method: ${response.statusCode}',
-
);
-
} on DioException catch (e) {
-
if (e.type == DioExceptionType.cancel) {
-
throw HandleResolverError('Handle resolution was cancelled');
-
}
-
-
throw HandleResolverError('Failed to resolve handle: ${e.message}', e);
-
} catch (e) {
-
if (e is HandleResolverError) rethrow;
-
-
throw HandleResolverError('Unexpected error resolving handle: $e', e);
-
}
-
}
-
}
-
-
/// Cached handle resolver that wraps another resolver with caching.
-
class CachedHandleResolver implements HandleResolver {
-
final HandleResolver _resolver;
-
final HandleCache _cache;
-
-
CachedHandleResolver(this._resolver, [HandleCache? cache])
-
: _cache = cache ?? InMemoryHandleCache();
-
-
@override
-
Future<String?> resolve(
-
String handle, [
-
ResolveHandleOptions? options,
-
]) async {
-
// Check cache first unless noCache is set
-
if (!(options?.noCache ?? false)) {
-
final cached = await _cache.get(handle);
-
if (cached != null) {
-
return cached;
-
}
-
}
-
-
// Resolve and cache
-
final did = await _resolver.resolve(handle, options);
-
if (did != null) {
-
await _cache.set(handle, did);
-
}
-
-
return did;
-
}
-
-
/// Clears the cache
-
Future<void> clearCache() => _cache.clear();
-
}
-
-
/// Interface for caching handle resolution results.
-
abstract class HandleCache {
-
Future<String?> get(String handle);
-
Future<void> set(String handle, String did);
-
Future<void> clear();
-
}
-
-
/// Simple in-memory handle cache with expiration.
-
class InMemoryHandleCache implements HandleCache {
-
final Map<String, _CacheEntry> _cache = {};
-
final Duration _ttl;
-
-
InMemoryHandleCache({Duration? ttl}) : _ttl = ttl ?? const Duration(hours: 1);
-
-
@override
-
Future<String?> get(String handle) async {
-
final entry = _cache[handle];
-
if (entry == null) return null;
-
-
// Check if expired
-
if (DateTime.now().isAfter(entry.expiresAt)) {
-
_cache.remove(handle);
-
return null;
-
}
-
-
return entry.did;
-
}
-
-
@override
-
Future<void> set(String handle, String did) async {
-
_cache[handle] = _CacheEntry(did: did, expiresAt: DateTime.now().add(_ttl));
-
}
-
-
@override
-
Future<void> clear() async {
-
_cache.clear();
-
}
-
}
-
-
class _CacheEntry {
-
final String did;
-
final DateTime expiresAt;
-
-
_CacheEntry({required this.did, required this.expiresAt});
-
}
-47
packages/atproto_oauth_flutter/lib/src/identity/identity.dart
···
-
/// Identity resolution for atProto.
-
///
-
/// This module provides the core identity resolution functionality for atProto,
-
/// enabling decentralized identity through handle and DID resolution.
-
///
-
/// ## Key Components
-
///
-
/// - **IdentityResolver**: Main interface for resolving handles/DIDs to identity info
-
/// - **HandleResolver**: Resolves atProto handles (e.g., "alice.bsky.social") to DIDs
-
/// - **DidResolver**: Resolves DIDs to DID documents
-
/// - **DidDocument**: Represents a DID document with services and handles
-
///
-
/// ## Why This Matters for Decentralization
-
///
-
/// This is the **most important module for atProto decentralization**. It enables:
-
/// 1. Users to host their data on any PDS, not just bsky.social
-
/// 2. Custom domain handles (e.g., "alice.example.com")
-
/// 3. Portable identity (change PDS without losing identity)
-
///
-
/// ## Usage
-
///
-
/// ```dart
-
/// // Create a resolver
-
/// final resolver = AtprotoIdentityResolver.withDefaults(
-
/// handleResolverUrl: 'https://bsky.social',
-
/// );
-
///
-
/// // Resolve a handle to find their PDS
-
/// final pdsUrl = await resolver.resolveToPds('alice.bsky.social');
-
/// print('Alice\'s PDS: $pdsUrl');
-
///
-
/// // Get full identity info
-
/// final info = await resolver.resolve('alice.bsky.social');
-
/// print('DID: ${info.did}');
-
/// print('Handle: ${info.handle}');
-
/// print('PDS: ${info.pdsUrl}');
-
/// ```
-
library;
-
-
export 'constants.dart';
-
export 'did_document.dart';
-
export 'did_helpers.dart';
-
export 'did_resolver.dart';
-
export 'handle_helpers.dart';
-
export 'handle_resolver.dart';
-
export 'identity_resolver.dart';
-
export 'identity_resolver_error.dart';
-366
packages/atproto_oauth_flutter/lib/src/identity/identity_resolver.dart
···
-
import 'package:dio/dio.dart';
-
-
import 'constants.dart';
-
import 'did_document.dart';
-
import 'did_helpers.dart';
-
import 'did_resolver.dart';
-
import 'handle_helpers.dart';
-
import 'handle_resolver.dart';
-
import 'identity_resolver_error.dart';
-
-
/// Represents resolved identity information for an atProto user.
-
///
-
/// This combines DID, DID document, and validated handle information.
-
class IdentityInfo {
-
/// The DID (Decentralized Identifier) for this identity
-
final String did;
-
-
/// The complete DID document
-
final DidDocument didDoc;
-
-
/// The validated handle, or 'handle.invalid' if handle validation failed
-
final String handle;
-
-
const IdentityInfo({
-
required this.did,
-
required this.didDoc,
-
required this.handle,
-
});
-
-
/// Whether the handle is valid (not 'handle.invalid')
-
bool get hasValidHandle => handle != handleInvalid;
-
-
/// Extracts the PDS URL from the DID document.
-
///
-
/// Returns null if no PDS service is found.
-
String? get pdsUrl => didDoc.extractPdsUrl();
-
}
-
-
/// Options for identity resolution.
-
class ResolveIdentityOptions {
-
/// Whether to bypass cache
-
final bool noCache;
-
-
/// Cancellation token for the request
-
final CancelToken? cancelToken;
-
-
const ResolveIdentityOptions({this.noCache = false, this.cancelToken});
-
}
-
-
/// Interface for resolving atProto identities (handles or DIDs) to complete identity info.
-
abstract class IdentityResolver {
-
/// Resolves an identifier (handle or DID) to complete identity information.
-
///
-
/// The identifier can be either:
-
/// - An atProto handle (e.g., "alice.bsky.social")
-
/// - A DID (e.g., "did:plc:...")
-
///
-
/// Returns [IdentityInfo] with DID, DID document, and validated handle.
-
Future<IdentityInfo> resolve(
-
String identifier, [
-
ResolveIdentityOptions? options,
-
]);
-
}
-
-
/// Implementation of the official atProto identity resolution strategy.
-
///
-
/// This resolver:
-
/// 1. Determines if input is a handle or DID
-
/// 2. Resolves handle โ†’ DID (if needed)
-
/// 3. Fetches DID document
-
/// 4. Validates bi-directional resolution (handle in DID doc matches original)
-
/// 5. Extracts PDS URL from DID document
-
///
-
/// This is the **critical piece for decentralization** - it ensures users can
-
/// host their data on any PDS, not just bsky.social.
-
class AtprotoIdentityResolver implements IdentityResolver {
-
final DidResolver didResolver;
-
final HandleResolver handleResolver;
-
-
AtprotoIdentityResolver({
-
required this.didResolver,
-
required this.handleResolver,
-
});
-
-
/// Factory constructor with defaults for typical usage.
-
///
-
/// [handleResolverUrl] should point to an atProto XRPC service that
-
/// implements com.atproto.identity.resolveHandle. Typically this is
-
/// https://bsky.social for public resolution, or your own PDS.
-
factory AtprotoIdentityResolver.withDefaults({
-
required String handleResolverUrl,
-
String? plcDirectoryUrl,
-
Dio? dio,
-
DidCache? didCache,
-
HandleCache? handleCache,
-
}) {
-
final dioInstance = dio ?? Dio();
-
-
final baseDidResolver = AtprotoDidResolver(
-
plcDirectoryUrl: plcDirectoryUrl,
-
dio: dioInstance,
-
);
-
-
final baseHandleResolver = XrpcHandleResolver(
-
handleResolverUrl,
-
dio: dioInstance,
-
);
-
-
return AtprotoIdentityResolver(
-
didResolver: CachedDidResolver(baseDidResolver, didCache),
-
handleResolver: CachedHandleResolver(baseHandleResolver, handleCache),
-
);
-
}
-
-
@override
-
Future<IdentityInfo> resolve(
-
String identifier, [
-
ResolveIdentityOptions? options,
-
]) async {
-
return isDid(identifier)
-
? resolveFromDid(identifier, options)
-
: resolveFromHandle(identifier, options);
-
}
-
-
/// Resolves identity starting from a DID.
-
///
-
/// This:
-
/// 1. Fetches the DID document
-
/// 2. Extracts the handle from alsoKnownAs
-
/// 3. Validates that the handle resolves back to the same DID
-
Future<IdentityInfo> resolveFromDid(
-
String did, [
-
ResolveIdentityOptions? options,
-
]) async {
-
final document = await getDocumentFromDid(did, options);
-
-
// We will only return the document's handle alias if it resolves to the
-
// same DID as the input (bi-directional validation)
-
final handle = document.extractNormalizedHandle();
-
String? resolvedDid;
-
-
if (handle != null) {
-
try {
-
resolvedDid = await handleResolver.resolve(
-
handle,
-
ResolveHandleOptions(
-
noCache: options?.noCache ?? false,
-
cancelToken: options?.cancelToken,
-
),
-
);
-
} catch (e) {
-
// Ignore errors (handle might be temporarily unavailable)
-
resolvedDid = null;
-
}
-
}
-
-
return IdentityInfo(
-
did: document.id,
-
didDoc: document,
-
handle: handle != null && resolvedDid == did ? handle : handleInvalid,
-
);
-
}
-
-
/// Resolves identity starting from a handle.
-
///
-
/// This:
-
/// 1. Resolves handle โ†’ DID
-
/// 2. Fetches DID document
-
/// 3. Validates that the DID document contains the original handle
-
Future<IdentityInfo> resolveFromHandle(
-
String handle, [
-
ResolveIdentityOptions? options,
-
]) async {
-
final document = await getDocumentFromHandle(handle, options);
-
-
// Bi-directional resolution is enforced in getDocumentFromHandle()
-
return IdentityInfo(
-
did: document.id,
-
didDoc: document,
-
handle: document.extractNormalizedHandle() ?? handleInvalid,
-
);
-
}
-
-
/// Fetches a DID document from a DID.
-
Future<DidDocument> getDocumentFromDid(
-
String did, [
-
ResolveIdentityOptions? options,
-
]) async {
-
return didResolver.resolve(
-
did,
-
ResolveDidOptions(
-
noCache: options?.noCache ?? false,
-
cancelToken: options?.cancelToken,
-
),
-
);
-
}
-
-
/// Fetches a DID document from a handle with bi-directional validation.
-
///
-
/// This method:
-
/// 1. Normalizes and validates the handle
-
/// 2. Resolves handle โ†’ DID
-
/// 3. Fetches DID document
-
/// 4. Verifies the DID document contains the original handle
-
Future<DidDocument> getDocumentFromHandle(
-
String input, [
-
ResolveIdentityOptions? options,
-
]) async {
-
final handle = asNormalizedHandle(input);
-
if (handle == null) {
-
throw InvalidHandleError(input, 'Invalid handle format');
-
}
-
-
final did = await handleResolver.resolve(
-
handle,
-
ResolveHandleOptions(
-
noCache: options?.noCache ?? false,
-
cancelToken: options?.cancelToken,
-
),
-
);
-
-
if (did == null) {
-
throw IdentityResolverError('Handle "$handle" does not resolve to a DID');
-
}
-
-
// Fetch the DID document
-
final document = await didResolver.resolve(
-
did,
-
ResolveDidOptions(
-
noCache: options?.noCache ?? false,
-
cancelToken: options?.cancelToken,
-
),
-
);
-
-
// Enforce bi-directional resolution
-
final docHandle = document.extractNormalizedHandle();
-
if (handle != docHandle) {
-
throw IdentityResolverError(
-
'DID document for "$did" does not include the handle "$handle" '
-
'(found: ${docHandle ?? "none"})',
-
);
-
}
-
-
return document;
-
}
-
-
/// Convenience method to resolve directly to PDS URL.
-
///
-
/// This is the most common use case: given a handle or DID, find the PDS URL.
-
Future<String> resolveToPds(
-
String identifier, [
-
ResolveIdentityOptions? options,
-
]) async {
-
final info = await resolve(identifier, options);
-
final pdsUrl = info.pdsUrl;
-
-
if (pdsUrl == null) {
-
throw IdentityResolverError(
-
'No PDS endpoint found in DID document for $identifier',
-
);
-
}
-
-
return pdsUrl;
-
}
-
}
-
-
/// Options for creating an identity resolver.
-
class IdentityResolverOptions {
-
/// Custom identity resolver (if not provided, AtprotoIdentityResolver is used)
-
final IdentityResolver? identityResolver;
-
-
/// Custom DID resolver
-
final DidResolver? didResolver;
-
-
/// Custom handle resolver (or URL string for XRPC resolver)
-
final dynamic handleResolver; // HandleResolver, String, or Uri
-
-
/// Custom DID cache
-
final DidCache? didCache;
-
-
/// Custom handle cache
-
final HandleCache? handleCache;
-
-
/// Custom Dio instance for HTTP requests
-
final Dio? dio;
-
-
/// PLC directory URL (defaults to https://plc.directory/)
-
final String? plcDirectoryUrl;
-
-
const IdentityResolverOptions({
-
this.identityResolver,
-
this.didResolver,
-
this.handleResolver,
-
this.didCache,
-
this.handleCache,
-
this.dio,
-
this.plcDirectoryUrl,
-
});
-
}
-
-
/// Creates an identity resolver with the given options.
-
///
-
/// This is the main entry point for creating an identity resolver.
-
/// It handles setting up default implementations with proper caching.
-
IdentityResolver createIdentityResolver(IdentityResolverOptions options) {
-
// If a custom identity resolver is provided, use it
-
if (options.identityResolver != null) {
-
return options.identityResolver!;
-
}
-
-
final dioInstance = options.dio ?? Dio();
-
-
// Create DID resolver
-
final didResolver = _createDidResolver(options, dioInstance);
-
-
// Create handle resolver
-
final handleResolver = _createHandleResolver(options, dioInstance);
-
-
return AtprotoIdentityResolver(
-
didResolver: didResolver,
-
handleResolver: handleResolver,
-
);
-
}
-
-
DidResolver _createDidResolver(IdentityResolverOptions options, Dio dio) {
-
final didResolver =
-
options.didResolver ??
-
AtprotoDidResolver(plcDirectoryUrl: options.plcDirectoryUrl, dio: dio);
-
-
// Wrap with cache if not already cached
-
if (didResolver is CachedDidResolver && options.didCache == null) {
-
return didResolver;
-
}
-
-
return CachedDidResolver(didResolver, options.didCache);
-
}
-
-
HandleResolver _createHandleResolver(IdentityResolverOptions options, Dio dio) {
-
final handleResolverInput = options.handleResolver;
-
-
if (handleResolverInput == null) {
-
throw ArgumentError(
-
'handleResolver is required. Provide either a HandleResolver instance, '
-
'a URL string, or a Uri pointing to an XRPC service.',
-
);
-
}
-
-
HandleResolver baseResolver;
-
-
if (handleResolverInput is HandleResolver) {
-
baseResolver = handleResolverInput;
-
} else if (handleResolverInput is String || handleResolverInput is Uri) {
-
baseResolver = XrpcHandleResolver(handleResolverInput.toString(), dio: dio);
-
} else {
-
throw ArgumentError(
-
'handleResolver must be a HandleResolver, String, or Uri',
-
);
-
}
-
-
// Wrap with cache if not already cached
-
if (baseResolver is CachedHandleResolver && options.handleCache == null) {
-
return baseResolver;
-
}
-
-
return CachedHandleResolver(baseResolver, options.handleCache);
-
}
-53
packages/atproto_oauth_flutter/lib/src/identity/identity_resolver_error.dart
···
-
/// Error thrown when identity resolution fails.
-
///
-
/// This error is thrown when resolving an atProto handle or DID fails,
-
/// including cases such as:
-
/// - Invalid handle format
-
/// - Handle doesn't resolve to a DID
-
/// - DID document is malformed or missing required fields
-
/// - Bi-directional resolution fails (handle in DID doc doesn't match)
-
class IdentityResolverError extends Error {
-
/// The error message describing what went wrong
-
final String message;
-
-
/// Optional underlying cause of the error
-
final Object? cause;
-
-
IdentityResolverError(this.message, [this.cause]);
-
-
@override
-
String toString() {
-
if (cause != null) {
-
return 'IdentityResolverError: $message\nCaused by: $cause';
-
}
-
return 'IdentityResolverError: $message';
-
}
-
}
-
-
/// Error thrown when a DID is invalid or malformed.
-
class InvalidDidError extends IdentityResolverError {
-
/// The invalid DID that was provided
-
final String did;
-
-
InvalidDidError(this.did, String message, [Object? cause])
-
: super('Invalid DID "$did": $message', cause);
-
}
-
-
/// Error thrown when a handle is invalid or malformed.
-
class InvalidHandleError extends IdentityResolverError {
-
/// The invalid handle that was provided
-
final String handle;
-
-
InvalidHandleError(this.handle, String message, [Object? cause])
-
: super('Invalid handle "$handle": $message', cause);
-
}
-
-
/// Error thrown when handle resolution fails.
-
class HandleResolverError extends IdentityResolverError {
-
HandleResolverError(super.message, [super.cause]);
-
}
-
-
/// Error thrown when DID resolution fails.
-
class DidResolverError extends IdentityResolverError {
-
DidResolverError(super.message, [super.cause]);
-
}
-248
packages/atproto_oauth_flutter/lib/src/oauth/authorization_server_metadata_resolver.dart
···
-
import 'package:dio/dio.dart';
-
-
import '../dpop/fetch_dpop.dart';
-
import '../util.dart';
-
-
/// Options for getting cached values.
-
class GetCachedOptions {
-
/// Whether to bypass cache and force a fresh fetch
-
final bool noCache;
-
-
/// Whether to allow returning stale cached values
-
final bool allowStale;
-
-
/// Optional cancellation token
-
final CancelToken? cancelToken;
-
-
const GetCachedOptions({
-
this.noCache = false,
-
this.allowStale = true,
-
this.cancelToken,
-
});
-
}
-
-
/// Cache interface for authorization server metadata.
-
///
-
/// Implementations should store metadata keyed by issuer URL.
-
typedef AuthorizationServerMetadataCache =
-
SimpleStore<String, Map<String, dynamic>>;
-
-
/// Configuration for the authorization server metadata resolver.
-
class OAuthAuthorizationServerMetadataResolverConfig {
-
/// Whether to allow HTTP (non-HTTPS) issuer URLs.
-
///
-
/// Should only be true in development/test environments.
-
/// Production MUST use HTTPS.
-
final bool allowHttpIssuer;
-
-
const OAuthAuthorizationServerMetadataResolverConfig({
-
this.allowHttpIssuer = false,
-
});
-
}
-
-
/// Resolves OAuth Authorization Server Metadata via RFC 8414 discovery.
-
///
-
/// This class:
-
/// 1. Validates issuer URLs (must be HTTPS in production)
-
/// 2. Fetches metadata from `{issuer}/.well-known/oauth-authorization-server`
-
/// 3. Validates the metadata against the spec
-
/// 4. Verifies issuer matches (prevents MIX-UP attacks)
-
/// 5. Ensures ATPROTO requirements (client_id_metadata_document)
-
/// 6. Caches metadata to avoid repeated fetches
-
///
-
/// See: https://datatracker.ietf.org/doc/html/rfc8414
-
class OAuthAuthorizationServerMetadataResolver {
-
final AuthorizationServerMetadataCache _cache;
-
final Dio _dio;
-
final bool _allowHttpIssuer;
-
-
/// Creates a resolver with the given cache and HTTP client.
-
///
-
/// [cache] is used to store fetched metadata. Use an in-memory store for
-
/// testing or a persistent store for production.
-
///
-
/// [dio] is the HTTP client. If not provided, creates a default instance.
-
///
-
/// [config] allows customizing behavior (e.g., allowing HTTP in tests).
-
OAuthAuthorizationServerMetadataResolver(
-
this._cache, {
-
Dio? dio,
-
OAuthAuthorizationServerMetadataResolverConfig? config,
-
}) : _dio = dio ?? Dio(),
-
_allowHttpIssuer = config?.allowHttpIssuer ?? false;
-
-
/// Resolves authorization server metadata for the given issuer.
-
///
-
/// The [input] should be a valid issuer identifier (typically an HTTPS URL).
-
///
-
/// Returns the complete metadata as a Map. Throws if:
-
/// - Input is not a valid issuer URL
-
/// - HTTP is used in production (allowHttpIssuer = false)
-
/// - Network request fails
-
/// - Response is not valid JSON
-
/// - Metadata validation fails
-
/// - Issuer mismatch detected
-
/// - ATPROTO requirements not met
-
///
-
/// Example:
-
/// ```dart
-
/// final resolver = OAuthAuthorizationServerMetadataResolver(cache);
-
/// final metadata = await resolver.get('https://pds.example.com');
-
/// print(metadata['authorization_endpoint']);
-
/// ```
-
Future<Map<String, dynamic>> get(
-
String input, [
-
GetCachedOptions? options,
-
]) async {
-
// Validate and normalize issuer URL
-
final issuer = _validateIssuer(input);
-
-
// Security check: disallow HTTP in production
-
if (!_allowHttpIssuer && issuer.startsWith('http:')) {
-
throw FormatException(
-
'Unsecure issuer URL protocol only allowed in development and test environments',
-
);
-
}
-
-
// Check cache first (unless noCache is set)
-
if (options?.noCache != true) {
-
final cached = await _cache.get(issuer);
-
if (cached != null) {
-
return cached;
-
}
-
}
-
-
// Fetch fresh metadata
-
final metadata = await _fetchMetadata(issuer, options);
-
-
// Store in cache
-
await _cache.set(issuer, metadata);
-
-
return metadata;
-
}
-
-
/// Fetches metadata from the well-known endpoint.
-
Future<Map<String, dynamic>> _fetchMetadata(
-
String issuer,
-
GetCachedOptions? options,
-
) async {
-
final url =
-
Uri.parse(
-
issuer,
-
).replace(path: '/.well-known/oauth-authorization-server').toString();
-
-
try {
-
final response = await _dio.get<Map<String, dynamic>>(
-
url,
-
options: Options(
-
headers: {'accept': 'application/json'},
-
followRedirects: false, // response must be 200 OK, no redirects
-
validateStatus: (status) => status == 200,
-
),
-
cancelToken: options?.cancelToken,
-
);
-
-
// Verify content type
-
final contentType = contentMime(
-
response.headers.map.map((key, value) => MapEntry(key, value.first)),
-
);
-
-
if (contentType != 'application/json') {
-
throw DioException(
-
requestOptions: response.requestOptions,
-
response: response,
-
type: DioExceptionType.badResponse,
-
message: 'Unexpected content type for "$url"',
-
);
-
}
-
-
final metadata = response.data;
-
if (metadata == null) {
-
throw DioException(
-
requestOptions: response.requestOptions,
-
response: response,
-
type: DioExceptionType.badResponse,
-
message: 'Empty response body for "$url"',
-
);
-
}
-
-
// Validate metadata structure
-
_validateMetadata(metadata, issuer);
-
-
return metadata;
-
} on DioException catch (e) {
-
if (e.response?.statusCode == 200) {
-
// Already handled above, rethrow
-
rethrow;
-
}
-
throw DioException(
-
requestOptions: e.requestOptions,
-
response: e.response,
-
type: e.type,
-
message:
-
'Unexpected status code ${e.response?.statusCode ?? 'unknown'} for "$url"',
-
error: e.error,
-
);
-
}
-
}
-
-
/// Validates an issuer identifier.
-
///
-
/// Ensures the issuer is a valid URL without query or fragment.
-
/// Returns the normalized issuer.
-
String _validateIssuer(String input) {
-
final uri = Uri.tryParse(input);
-
if (uri == null) {
-
throw FormatException('Invalid issuer URL: $input');
-
}
-
-
// Issuer must not have query or fragment
-
if (uri.hasQuery || uri.hasFragment) {
-
throw FormatException(
-
'Issuer URL must not contain query or fragment: $input',
-
);
-
}
-
-
// Normalize: remove trailing slash
-
final normalized =
-
input.endsWith('/') ? input.substring(0, input.length - 1) : input;
-
-
return normalized;
-
}
-
-
/// Validates authorization server metadata.
-
///
-
/// Checks:
-
/// - Required fields are present
-
/// - Issuer matches expected value (MIX-UP attack prevention)
-
/// - ATPROTO requirement: client_id_metadata_document_supported = true
-
void _validateMetadata(Map<String, dynamic> metadata, String expectedIssuer) {
-
// Validate issuer field (critical for security - prevents MIX-UP attacks)
-
// https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics#name-mix-up-attacks
-
// https://datatracker.ietf.org/doc/html/rfc8414#section-2
-
final issuer = metadata['issuer'];
-
if (issuer != expectedIssuer) {
-
throw FormatException(
-
'Invalid issuer: expected "$expectedIssuer", got "$issuer"',
-
);
-
}
-
-
// ATPROTO requires client_id_metadata_document support
-
// https://datatracker.ietf.org/doc/draft-ietf-oauth-client-id-metadata-document/
-
final clientIdMetadataSupported =
-
metadata['client_id_metadata_document_supported'];
-
if (clientIdMetadataSupported != true) {
-
throw FormatException(
-
'Authorization server "$issuer" does not support client_id_metadata_document',
-
);
-
}
-
-
// Validate required endpoints exist
-
if (metadata['authorization_endpoint'] == null) {
-
throw FormatException('Missing required field: authorization_endpoint');
-
}
-
if (metadata['token_endpoint'] == null) {
-
throw FormatException('Missing required field: token_endpoint');
-
}
-
}
-
}
-285
packages/atproto_oauth_flutter/lib/src/oauth/client_auth.dart
···
-
import '../constants.dart';
-
import '../errors/auth_method_unsatisfiable_error.dart';
-
import '../runtime/runtime.dart';
-
import '../runtime/runtime_implementation.dart';
-
import '../types.dart';
-
-
/// Represents a client authentication method.
-
///
-
/// OAuth supports different ways for clients to authenticate with the
-
/// authorization server:
-
/// - 'none': Public client (no secret), only client_id
-
/// - 'private_key_jwt': Confidential client using JWT signed with private key
-
class ClientAuthMethod {
-
final String method;
-
final String? kid; // Key ID for private_key_jwt method
-
-
const ClientAuthMethod.none() : method = 'none', kid = null;
-
-
const ClientAuthMethod.privateKeyJwt(this.kid) : method = 'private_key_jwt';
-
-
@override
-
bool operator ==(Object other) {
-
if (identical(this, other)) return true;
-
return other is ClientAuthMethod &&
-
other.method == method &&
-
other.kid == kid;
-
}
-
-
@override
-
int get hashCode => method.hashCode ^ kid.hashCode;
-
-
Map<String, dynamic> toJson() {
-
return {'method': method, if (kid != null) 'kid': kid};
-
}
-
-
factory ClientAuthMethod.fromJson(Map<String, dynamic> json) {
-
final method = json['method'] as String;
-
if (method == 'none') {
-
return const ClientAuthMethod.none();
-
} else if (method == 'private_key_jwt') {
-
return ClientAuthMethod.privateKeyJwt(json['kid'] as String);
-
}
-
throw FormatException('Unknown auth method: $method');
-
}
-
}
-
-
/// Credential payload to include in OAuth requests.
-
class OAuthClientCredentials {
-
/// Client identifier
-
final String clientId;
-
-
/// Client assertion type (for private_key_jwt)
-
final String? clientAssertionType;
-
-
/// Client assertion JWT (for private_key_jwt)
-
final String? clientAssertion;
-
-
const OAuthClientCredentials({
-
required this.clientId,
-
this.clientAssertionType,
-
this.clientAssertion,
-
});
-
-
Map<String, dynamic> toJson() {
-
final map = <String, dynamic>{'client_id': clientId};
-
if (clientAssertionType != null) {
-
map['client_assertion_type'] = clientAssertionType;
-
}
-
if (clientAssertion != null) {
-
map['client_assertion'] = clientAssertion;
-
}
-
return map;
-
}
-
}
-
-
/// Result of creating client credentials.
-
class ClientCredentialsResult {
-
/// Optional HTTP headers (e.g., Authorization header for client_secret_basic)
-
final Map<String, String>? headers;
-
-
/// Payload to include in the request body
-
final OAuthClientCredentials payload;
-
-
const ClientCredentialsResult({this.headers, required this.payload});
-
}
-
-
/// Factory function that creates client credentials.
-
typedef ClientCredentialsFactory = Future<ClientCredentialsResult> Function();
-
-
/// Negotiates the client authentication method to use.
-
///
-
/// This function:
-
/// 1. Checks that the server supports the client's auth method
-
/// 2. For private_key_jwt, finds a suitable key from the keyset
-
/// 3. Returns the negotiated auth method
-
///
-
/// The ATPROTO spec requires that authorization servers support both
-
/// "none" and "private_key_jwt", and clients use one or the other.
-
///
-
/// Throws:
-
/// - Error if server doesn't support client's auth method
-
/// - Error if private_key_jwt is used but no suitable key is found
-
ClientAuthMethod negotiateClientAuthMethod(
-
Map<String, dynamic> serverMetadata,
-
ClientMetadata clientMetadata,
-
Keyset? keyset,
-
) {
-
final method = clientMetadata.tokenEndpointAuthMethod;
-
-
// Check that the server supports this method
-
final methods = _supportedMethods(serverMetadata);
-
if (!methods.contains(method)) {
-
throw StateError(
-
'The server does not support "$method" authentication. '
-
'Supported methods are: ${methods.join(', ')}.',
-
);
-
}
-
-
if (method == 'private_key_jwt') {
-
// Invalid client configuration
-
if (keyset == null) {
-
throw StateError('A keyset is required for private_key_jwt');
-
}
-
-
final algs = _supportedAlgs(serverMetadata);
-
-
// Find a suitable key
-
// We can't use keyset.findPrivateKey here because we need to ensure
-
// the key has a "kid" property (required for JWT headers)
-
for (final key in keyset.keys) {
-
if (key.kid != null &&
-
key.usage == 'sign' &&
-
key.algorithms.any((a) => algs.contains(a))) {
-
return ClientAuthMethod.privateKeyJwt(key.kid!);
-
}
-
}
-
-
throw StateError(
-
algs.contains(fallbackAlg)
-
? 'Client authentication method "$method" requires at least one "$fallbackAlg" signing key with a "kid" property'
-
: 'Authorization server requires "$method" authentication method, but does not support "$fallbackAlg" algorithm.',
-
);
-
}
-
-
if (method == 'none') {
-
return const ClientAuthMethod.none();
-
}
-
-
throw StateError(
-
'The ATProto OAuth spec requires that client use either "none" or "private_key_jwt" authentication method.' +
-
(method == 'client_secret_basic'
-
? ' You might want to explicitly set "token_endpoint_auth_method" to one of those values in the client metadata document.'
-
: ' You set "$method" which is not allowed.'),
-
);
-
}
-
-
/// Creates a factory that generates client credentials.
-
///
-
/// The factory can be called multiple times to generate fresh credentials
-
/// (important for private_key_jwt which includes timestamps).
-
///
-
/// Throws [AuthMethodUnsatisfiableError] if:
-
/// - Server no longer supports the auth method
-
/// - Key is no longer available in the keyset
-
ClientCredentialsFactory createClientCredentialsFactory(
-
ClientAuthMethod authMethod,
-
Map<String, dynamic> serverMetadata,
-
ClientMetadata clientMetadata,
-
Runtime runtime,
-
Keyset? keyset,
-
) {
-
// Ensure the AS still supports the auth method
-
if (!_supportedMethods(serverMetadata).contains(authMethod.method)) {
-
throw AuthMethodUnsatisfiableError(
-
'Client authentication method "${authMethod.method}" no longer supported',
-
);
-
}
-
-
if (authMethod.method == 'none') {
-
return () async => ClientCredentialsResult(
-
payload: OAuthClientCredentials(clientId: clientMetadata.clientId!),
-
);
-
}
-
-
if (authMethod.method == 'private_key_jwt') {
-
try {
-
// Find the key
-
if (keyset == null) {
-
throw StateError('A keyset is required for private_key_jwt');
-
}
-
-
final key = keyset.keys.firstWhere(
-
(k) =>
-
k.kid == authMethod.kid &&
-
k.usage == 'sign' &&
-
k.algorithms.any((a) => _supportedAlgs(serverMetadata).contains(a)),
-
orElse: () => throw StateError('Key not found: ${authMethod.kid}'),
-
);
-
-
final alg = key.algorithms.firstWhere(
-
(a) => _supportedAlgs(serverMetadata).contains(a),
-
orElse: () => throw StateError('No supported algorithm found'),
-
);
-
-
// https://www.rfc-editor.org/rfc/rfc7523.html#section-3
-
return () async {
-
final jti = await runtime.generateNonce();
-
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
-
-
final jwt = await key.createJwt(
-
{'alg': alg},
-
{
-
// Issuer: the client_id
-
'iss': clientMetadata.clientId,
-
// Subject: the client_id
-
'sub': clientMetadata.clientId,
-
// Audience: the authorization server
-
'aud': serverMetadata['issuer'],
-
// JWT ID: unique identifier
-
'jti': jti,
-
// Issued at
-
'iat': now,
-
// Expiration: 1 minute from now
-
'exp': now + 60,
-
},
-
);
-
-
return ClientCredentialsResult(
-
payload: OAuthClientCredentials(
-
clientId: clientMetadata.clientId!,
-
clientAssertionType:
-
'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
-
clientAssertion: jwt,
-
),
-
);
-
};
-
} catch (cause) {
-
throw AuthMethodUnsatisfiableError('Failed to load private key: $cause');
-
}
-
}
-
-
throw AuthMethodUnsatisfiableError(
-
'Unsupported auth method: ${authMethod.method}',
-
);
-
}
-
-
/// Gets the list of supported authentication methods from server metadata.
-
List<String> _supportedMethods(Map<String, dynamic> serverMetadata) {
-
final methods = serverMetadata['token_endpoint_auth_methods_supported'];
-
if (methods is List) {
-
return methods.map((m) => m.toString()).toList();
-
}
-
return [];
-
}
-
-
/// Gets the list of supported signing algorithms from server metadata.
-
List<String> _supportedAlgs(Map<String, dynamic> serverMetadata) {
-
final algs =
-
serverMetadata['token_endpoint_auth_signing_alg_values_supported'];
-
if (algs is List) {
-
return algs.map((a) => a.toString()).toList();
-
}
-
-
// Default to ES256 as prescribed by the ATProto spec:
-
// > Clients and Authorization Servers currently must support the ES256
-
// > cryptographic system [for client authentication].
-
// https://atproto.com/specs/oauth#confidential-client-authentication
-
return [fallbackAlg];
-
}
-
-
/// Placeholder for Keyset class.
-
///
-
/// In the full implementation, this would come from @atproto/jwk package.
-
/// For now, we use a simple implementation.
-
class Keyset {
-
final List<Key> keys;
-
-
const Keyset(this.keys);
-
-
int get size => keys.length;
-
-
Map<String, dynamic> toJSON() {
-
return {'keys': keys.map((k) => k.bareJwk).toList()};
-
}
-
}
-307
packages/atproto_oauth_flutter/lib/src/oauth/oauth_resolver.dart
···
-
import '../errors/oauth_resolver_error.dart';
-
import '../identity/did_document.dart';
-
import '../identity/identity_resolver.dart';
-
import 'authorization_server_metadata_resolver.dart';
-
import 'protected_resource_metadata_resolver.dart';
-
-
/// Complete result of OAuth resolution from an identity.
-
class ResolvedOAuthIdentityFromIdentity {
-
/// The resolved identity information
-
final IdentityInfo identityInfo;
-
-
/// The authorization server metadata
-
final Map<String, dynamic> metadata;
-
-
/// The PDS URL
-
final Uri pds;
-
-
const ResolvedOAuthIdentityFromIdentity({
-
required this.identityInfo,
-
required this.metadata,
-
required this.pds,
-
});
-
}
-
-
/// Result of OAuth resolution from a service URL.
-
class ResolvedOAuthIdentityFromService {
-
/// The authorization server metadata
-
final Map<String, dynamic> metadata;
-
-
/// Optional identity info (only present if resolved from handle/DID)
-
final IdentityInfo? identityInfo;
-
-
const ResolvedOAuthIdentityFromService({
-
required this.metadata,
-
this.identityInfo,
-
});
-
}
-
-
/// Options for OAuth resolution.
-
typedef ResolveOAuthOptions = GetCachedOptions;
-
-
/// Main OAuth resolver that combines identity and metadata resolution.
-
///
-
/// This class orchestrates the complete OAuth discovery flow:
-
///
-
/// 1. **From handle/DID** (resolveFromIdentity):
-
/// - Resolve handle โ†’ DID (if needed)
-
/// - Fetch DID document
-
/// - Extract PDS URL from DID document
-
/// - Fetch protected resource metadata from PDS
-
/// - Extract authorization server(s) from resource metadata
-
/// - Fetch authorization server metadata
-
/// - Verify PDS is protected by the authorization server
-
///
-
/// 2. **From URL** (resolveFromService):
-
/// - Try as PDS URL (fetch protected resource metadata)
-
/// - Extract authorization server from metadata
-
/// - Fallback: try as authorization server directly
-
///
-
/// This is the critical piece that enables decentralization - users can
-
/// host their data on any PDS, and we discover the OAuth server dynamically.
-
class OAuthResolver {
-
final IdentityResolver identityResolver;
-
final OAuthProtectedResourceMetadataResolver
-
protectedResourceMetadataResolver;
-
final OAuthAuthorizationServerMetadataResolver
-
authorizationServerMetadataResolver;
-
-
OAuthResolver({
-
required this.identityResolver,
-
required this.protectedResourceMetadataResolver,
-
required this.authorizationServerMetadataResolver,
-
});
-
-
/// Resolves OAuth metadata from an input (handle, DID, or URL).
-
///
-
/// The [input] can be:
-
/// - An atProto handle (e.g., "alice.bsky.social")
-
/// - A DID (e.g., "did:plc:...")
-
/// - A PDS URL (e.g., "https://pds.example.com")
-
/// - An authorization server URL (e.g., "https://auth.example.com")
-
///
-
/// Returns metadata for the authorization server. The identityInfo
-
/// is only present if input was a handle or DID.
-
Future<ResolvedOAuthIdentityFromService> resolve(
-
String input, [
-
ResolveOAuthOptions? options,
-
]) async {
-
// Detect if input is a URL (starts with http:// or https://)
-
if (RegExp(r'^https?://').hasMatch(input)) {
-
return resolveFromService(input, options);
-
} else {
-
final result = await resolveFromIdentity(input, options);
-
return ResolvedOAuthIdentityFromService(
-
metadata: result.metadata,
-
identityInfo: result.identityInfo,
-
);
-
}
-
}
-
-
/// Resolves OAuth metadata from a service URL (PDS or authorization server).
-
///
-
/// This method:
-
/// 1. First tries to resolve as a PDS (protected resource)
-
/// 2. If that fails, tries to resolve as an authorization server directly
-
///
-
/// This allows both "login with PDS URL" and "login with auth server URL"
-
/// flows, useful when users forget their handle or for compatibility.
-
Future<ResolvedOAuthIdentityFromService> resolveFromService(
-
String input, [
-
ResolveOAuthOptions? options,
-
]) async {
-
try {
-
// Assume first that input is a PDS URL (as required by ATPROTO)
-
final metadata = await getResourceServerMetadata(input, options);
-
return ResolvedOAuthIdentityFromService(metadata: metadata);
-
} catch (err) {
-
// Check if request was cancelled - note: Dio's CancelToken doesn't have throwIfCanceled()
-
// We rely on Dio throwing CancelError automatically
-
-
if (err is OAuthResolverError) {
-
try {
-
// Fallback to trying to fetch as an issuer (Entryway/Authorization Server)
-
final issuerUri = Uri.tryParse(input);
-
if (issuerUri != null && issuerUri.hasScheme) {
-
final metadata = await getAuthorizationServerMetadata(
-
input,
-
options,
-
);
-
return ResolvedOAuthIdentityFromService(metadata: metadata);
-
}
-
} catch (_) {
-
// Fallback failed, throw original error
-
}
-
}
-
-
rethrow;
-
}
-
}
-
-
/// Resolves OAuth metadata from a handle or DID.
-
///
-
/// This is the primary OAuth discovery flow:
-
/// 1. Resolve handle โ†’ DID โ†’ DID document (via IdentityResolver)
-
/// 2. Extract PDS URL from DID document
-
/// 3. Get protected resource metadata from PDS
-
/// 4. Extract authorization server(s)
-
/// 5. Get authorization server metadata
-
/// 6. Verify PDS is protected by the auth server
-
Future<ResolvedOAuthIdentityFromIdentity> resolveFromIdentity(
-
String input, [
-
ResolveOAuthOptions? options,
-
]) async {
-
final identityInfo = await resolveIdentity(
-
input,
-
options != null
-
? ResolveIdentityOptions(
-
noCache: options.noCache,
-
cancelToken: options.cancelToken,
-
)
-
: null,
-
);
-
-
final pds = _extractPdsUrl(identityInfo.didDoc);
-
-
final metadata = await getResourceServerMetadata(pds, options);
-
-
return ResolvedOAuthIdentityFromIdentity(
-
identityInfo: identityInfo,
-
metadata: metadata,
-
pds: pds,
-
);
-
}
-
-
/// Resolves an identity (handle or DID) to IdentityInfo.
-
///
-
/// Wraps the IdentityResolver with proper error handling.
-
Future<IdentityInfo> resolveIdentity(
-
String input, [
-
ResolveIdentityOptions? options,
-
]) async {
-
try {
-
return await identityResolver.resolve(input, options);
-
} catch (cause) {
-
throw OAuthResolverError.from(
-
cause,
-
'Failed to resolve identity: $input',
-
);
-
}
-
}
-
-
/// Gets authorization server metadata for an issuer.
-
///
-
/// Wraps the AuthorizationServerMetadataResolver with proper error handling.
-
Future<Map<String, dynamic>> getAuthorizationServerMetadata(
-
String issuer, [
-
GetCachedOptions? options,
-
]) async {
-
try {
-
return await authorizationServerMetadataResolver.get(issuer, options);
-
} catch (cause) {
-
throw OAuthResolverError.from(
-
cause,
-
'Failed to resolve OAuth server metadata for issuer: $issuer',
-
);
-
}
-
}
-
-
/// Gets authorization server metadata for a protected resource (PDS).
-
///
-
/// This method:
-
/// 1. Fetches protected resource metadata
-
/// 2. Validates exactly one authorization server is listed (ATPROTO requirement)
-
/// 3. Fetches authorization server metadata
-
/// 4. Verifies the PDS is in the auth server's protected_resources list
-
Future<Map<String, dynamic>> getResourceServerMetadata(
-
dynamic pdsUrl, [
-
GetCachedOptions? options,
-
]) async {
-
try {
-
final rsMetadata = await protectedResourceMetadataResolver.get(
-
pdsUrl,
-
options,
-
);
-
-
// ATPROTO requires exactly one authorization server
-
final authServers = rsMetadata['authorization_servers'];
-
if (authServers is! List || authServers.length != 1) {
-
throw OAuthResolverError(
-
authServers == null || (authServers as List).isEmpty
-
? 'No authorization servers found for PDS: $pdsUrl'
-
: 'Unable to determine authorization server for PDS: $pdsUrl',
-
);
-
}
-
-
final issuer = authServers[0] as String;
-
-
final asMetadata = await getAuthorizationServerMetadata(issuer, options);
-
-
// Verify PDS is protected by this authorization server
-
// https://www.rfc-editor.org/rfc/rfc9728.html#section-4
-
final protectedResources = asMetadata['protected_resources'];
-
if (protectedResources != null) {
-
final resource = rsMetadata['resource'] as String;
-
if (!(protectedResources as List).contains(resource)) {
-
throw OAuthResolverError(
-
'PDS "$pdsUrl" not protected by issuer "$issuer"',
-
);
-
}
-
}
-
-
return asMetadata;
-
} catch (cause) {
-
throw OAuthResolverError.from(
-
cause,
-
'Failed to resolve OAuth server metadata for resource: $pdsUrl',
-
);
-
}
-
}
-
-
/// Extracts the PDS URL from a DID document.
-
///
-
/// Throws OAuthResolverError if no PDS URL is found.
-
Uri _extractPdsUrl(DidDocument document) {
-
// Find the atproto_pds service
-
final service = document.service?.firstWhere(
-
(s) => _isAtprotoPersonalDataServerService(s, document),
-
orElse:
-
() =>
-
throw OAuthResolverError(
-
'Identity "${document.id}" does not have a PDS URL',
-
),
-
);
-
-
if (service == null) {
-
throw OAuthResolverError(
-
'Identity "${document.id}" does not have a PDS URL',
-
);
-
}
-
-
try {
-
return Uri.parse(service.serviceEndpoint as String);
-
} catch (cause) {
-
throw OAuthResolverError(
-
'Invalid PDS URL in DID document: ${service.serviceEndpoint}',
-
cause: cause,
-
);
-
}
-
}
-
-
/// Checks if a service is an AtprotoPersonalDataServer.
-
bool _isAtprotoPersonalDataServerService(
-
DidService service,
-
DidDocument document,
-
) {
-
if (service.serviceEndpoint is! String) return false;
-
if (service.type != 'AtprotoPersonalDataServer') return false;
-
-
// Check service ID
-
final id = service.id;
-
if (id.startsWith('#')) {
-
return id == '#atproto_pds';
-
} else {
-
return id == '${document.id}#atproto_pds';
-
}
-
}
-
}
-519
packages/atproto_oauth_flutter/lib/src/oauth/oauth_server_agent.dart
···
-
import 'package:dio/dio.dart';
-
import 'package:flutter/foundation.dart' hide Key;
-
-
import '../dpop/fetch_dpop.dart';
-
import '../errors/oauth_response_error.dart';
-
import '../errors/token_refresh_error.dart';
-
import '../runtime/runtime.dart';
-
import '../runtime/runtime_implementation.dart';
-
import '../types.dart';
-
import 'authorization_server_metadata_resolver.dart' show GetCachedOptions;
-
import 'client_auth.dart';
-
import 'oauth_resolver.dart';
-
-
/// Represents a token set returned from OAuth token endpoint.
-
class TokenSet {
-
/// Issuer (authorization server URL)
-
final String iss;
-
-
/// Subject (DID of the user)
-
final String sub;
-
-
/// Audience (PDS URL)
-
final String aud;
-
-
/// Scope (space-separated list of scopes)
-
final String scope;
-
-
/// Refresh token (optional)
-
final String? refreshToken;
-
-
/// Access token
-
final String accessToken;
-
-
/// Token type (must be "DPoP" for ATPROTO)
-
final String tokenType;
-
-
/// Expiration time (ISO date string)
-
final String? expiresAt;
-
-
const TokenSet({
-
required this.iss,
-
required this.sub,
-
required this.aud,
-
required this.scope,
-
this.refreshToken,
-
required this.accessToken,
-
required this.tokenType,
-
this.expiresAt,
-
});
-
-
Map<String, dynamic> toJson() {
-
return {
-
'iss': iss,
-
'sub': sub,
-
'aud': aud,
-
'scope': scope,
-
if (refreshToken != null) 'refresh_token': refreshToken,
-
'access_token': accessToken,
-
'token_type': tokenType,
-
if (expiresAt != null) 'expires_at': expiresAt,
-
};
-
}
-
-
factory TokenSet.fromJson(Map<String, dynamic> json) {
-
return TokenSet(
-
iss: json['iss'] as String,
-
sub: json['sub'] as String,
-
aud: json['aud'] as String,
-
scope: json['scope'] as String,
-
refreshToken: json['refresh_token'] as String?,
-
accessToken: json['access_token'] as String,
-
tokenType: json['token_type'] as String,
-
expiresAt: json['expires_at'] as String?,
-
);
-
}
-
}
-
-
/// DPoP nonce cache type.
-
typedef DpopNonceCache = SimpleStore<String, String>;
-
-
/// Agent for interacting with an OAuth authorization server.
-
///
-
/// This class handles:
-
/// - Token exchange (authorization code โ†’ tokens)
-
/// - Token refresh (refresh token โ†’ new tokens)
-
/// - Token revocation
-
/// - DPoP proof generation and nonce management
-
/// - Client authentication
-
///
-
/// All token requests include DPoP proofs to bind tokens to keys.
-
class OAuthServerAgent {
-
final ClientAuthMethod authMethod;
-
final Key dpopKey;
-
final Map<String, dynamic> serverMetadata;
-
final ClientMetadata clientMetadata;
-
final DpopNonceCache dpopNonces;
-
final OAuthResolver oauthResolver;
-
final Runtime runtime;
-
final Keyset? keyset;
-
final Dio _dio;
-
final ClientCredentialsFactory _clientCredentialsFactory;
-
-
/// Creates an OAuth server agent.
-
///
-
/// Throws [AuthMethodUnsatisfiableError] if the auth method cannot be satisfied.
-
OAuthServerAgent({
-
required this.authMethod,
-
required this.dpopKey,
-
required this.serverMetadata,
-
required this.clientMetadata,
-
required this.dpopNonces,
-
required this.oauthResolver,
-
required this.runtime,
-
this.keyset,
-
Dio? dio,
-
}) : // CRITICAL: Always create a NEW Dio instance to avoid duplicate interceptors
-
// If we reuse a shared Dio instance, each OAuthServerAgent will add its
-
// interceptors to the same instance, causing duplicate requests!
-
_dio = Dio(dio?.options ?? BaseOptions()),
-
_clientCredentialsFactory = createClientCredentialsFactory(
-
authMethod,
-
serverMetadata,
-
clientMetadata,
-
runtime,
-
keyset,
-
) {
-
// Add debug logging interceptor (runs before DPoP interceptor)
-
if (kDebugMode) {
-
_dio.interceptors.add(
-
InterceptorsWrapper(
-
onRequest: (options, handler) {
-
if (options.uri.path.contains('/token')) {
-
print(
-
'๐Ÿ“ค [BEFORE DPoP] Request headers: ${options.headers.keys.toList()}',
-
);
-
}
-
handler.next(options);
-
},
-
),
-
);
-
}
-
-
// Add DPoP interceptor
-
_dio.interceptors.add(
-
createDpopInterceptor(
-
DpopFetchWrapperOptions(
-
key: dpopKey,
-
nonces: dpopNonces,
-
sha256: runtime.sha256,
-
isAuthServer: true,
-
),
-
),
-
);
-
-
// Add final logging interceptor (runs after DPoP interceptor)
-
if (kDebugMode) {
-
_dio.interceptors.add(
-
InterceptorsWrapper(
-
onRequest: (options, handler) {
-
if (options.uri.path.contains('/token')) {
-
print(
-
'๐Ÿ“ค [AFTER DPoP] Request headers: ${options.headers.keys.toList()}',
-
);
-
if (options.headers.containsKey('dpop')) {
-
print(
-
' DPoP header present: ${options.headers['dpop']?.toString().substring(0, 50)}...',
-
);
-
} else if (options.headers.containsKey('DPoP')) {
-
print(
-
' DPoP header present: ${options.headers['DPoP']?.toString().substring(0, 50)}...',
-
);
-
} else {
-
print(' โš ๏ธ DPoP header MISSING!');
-
}
-
}
-
handler.next(options);
-
},
-
onError: (error, handler) {
-
if (error.requestOptions.uri.path.contains('/token')) {
-
print('๐Ÿ“ฅ Token request error: ${error.message}');
-
}
-
handler.next(error);
-
},
-
),
-
);
-
}
-
}
-
-
/// The issuer (authorization server URL).
-
String get issuer => serverMetadata['issuer'] as String;
-
-
/// Revokes a token.
-
///
-
/// Errors are silently ignored as revocation is best-effort.
-
Future<void> revoke(String token) async {
-
try {
-
await _request('revocation', {'token': token});
-
} catch (_) {
-
// Don't care if revocation fails
-
}
-
}
-
-
/// Pre-fetches a DPoP nonce from the token endpoint.
-
///
-
/// This is critical for authorization code exchange because:
-
/// 1. First token request without nonce โ†’ PDS consumes code + returns use_dpop_nonce error
-
/// 2. Retry with nonce โ†’ "Invalid code" because already consumed
-
///
-
/// Solution: Get a nonce BEFORE attempting code exchange.
-
///
-
/// We make a lightweight invalid request that will fail but return a nonce.
-
/// The server responds with a nonce in the DPoP-Nonce header, which the
-
/// interceptor automatically caches for subsequent requests.
-
Future<void> _prefetchDpopNonce() async {
-
final tokenEndpoint = serverMetadata['token_endpoint'] as String?;
-
if (tokenEndpoint == null) return;
-
-
final origin = Uri.parse(tokenEndpoint);
-
final originKey =
-
'${origin.scheme}://${origin.host}${origin.hasPort ? ':${origin.port}' : ''}';
-
-
// Clear any stale nonce from previous sessions
-
try {
-
await dpopNonces.del(originKey);
-
if (kDebugMode) {
-
print('๐Ÿงน Cleared stale DPoP nonce from cache');
-
}
-
} catch (_) {
-
// Ignore deletion errors
-
}
-
-
if (kDebugMode) {
-
print('โฑ๏ธ Pre-fetch starting at: ${DateTime.now().toIso8601String()}');
-
}
-
-
try {
-
// Make a minimal invalid request to trigger nonce response
-
// Use an invalid grant_type that will fail fast without side effects
-
await _dio.post<Map<String, dynamic>>(
-
tokenEndpoint,
-
data: 'grant_type=invalid_prefetch',
-
options: Options(
-
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
-
validateStatus: (status) => true, // Accept any status
-
),
-
);
-
} catch (_) {
-
// Ignore all errors - we just want the nonce from the response headers
-
// The DPoP interceptor will have cached it in onError or onResponse
-
}
-
-
if (kDebugMode) {
-
print('โฑ๏ธ Pre-fetch completed at: ${DateTime.now().toIso8601String()}');
-
final cachedNonce = await dpopNonces.get(originKey);
-
print('๐ŸŽซ DPoP nonce pre-fetch result:');
-
print(
-
' Cached nonce: ${cachedNonce != null ? "โœ… ${cachedNonce.substring(0, 20)}..." : "โŒ not found"}',
-
);
-
}
-
}
-
-
/// Exchanges an authorization code for tokens.
-
///
-
/// This is called after the user completes authorization and you receive
-
/// the authorization code in the callback.
-
///
-
/// [code] is the authorization code from the callback.
-
/// [codeVerifier] is the PKCE code verifier (if PKCE was used).
-
/// [redirectUri] is the redirect URI used in the authorization request.
-
///
-
/// Returns a [TokenSet] with access token, optional refresh token, and metadata.
-
///
-
/// IMPORTANT: This method verifies the issuer before returning tokens.
-
/// If verification fails, the access token is automatically revoked.
-
Future<TokenSet> exchangeCode(
-
String code, {
-
String? codeVerifier,
-
String? redirectUri,
-
}) async {
-
// CRITICAL: DO NOT pre-fetch! Exchange immediately!
-
// The pre-fetch adds ~678ms delay, during which the browser re-navigates
-
// and invalidates the authorization code. We need to exchange within ~270ms.
-
// If we get a nonce error, we'll handle it via the interceptor (though PDS
-
// doesn't seem to require nonces for initial token exchange).
-
-
final now = DateTime.now();
-
-
final tokenResponse = await _request('token', {
-
'grant_type': 'authorization_code',
-
'redirect_uri': redirectUri ?? clientMetadata.redirectUris.first,
-
'code': code,
-
if (codeVerifier != null) 'code_verifier': codeVerifier,
-
});
-
-
try {
-
// CRITICAL: Verify issuer before trusting the sub
-
// The tokenResponse MUST always be valid before the "sub" can be trusted
-
// See: https://atproto.com/specs/oauth
-
final aud = await _verifyIssuer(tokenResponse['sub'] as String);
-
-
return TokenSet(
-
aud: aud,
-
sub: tokenResponse['sub'] as String,
-
iss: issuer,
-
scope: tokenResponse['scope'] as String,
-
refreshToken: tokenResponse['refresh_token'] as String?,
-
accessToken: tokenResponse['access_token'] as String,
-
tokenType: tokenResponse['token_type'] as String,
-
expiresAt:
-
tokenResponse['expires_in'] != null
-
? now
-
.add(Duration(seconds: tokenResponse['expires_in'] as int))
-
.toIso8601String()
-
: null,
-
);
-
} catch (err) {
-
// If verification fails, revoke the access token
-
await revoke(tokenResponse['access_token'] as String);
-
rethrow;
-
}
-
}
-
-
/// Refreshes a token set using the refresh token.
-
///
-
/// [tokenSet] is the current token set with a refresh_token.
-
///
-
/// Returns a new [TokenSet] with fresh tokens.
-
///
-
/// Throws [TokenRefreshError] if refresh fails or no refresh token is available.
-
///
-
/// IMPORTANT: This method verifies the issuer before returning tokens.
-
Future<TokenSet> refresh(TokenSet tokenSet) async {
-
if (tokenSet.refreshToken == null) {
-
throw TokenRefreshError(tokenSet.sub, 'No refresh token available');
-
}
-
-
// CRITICAL: Verify issuer BEFORE refresh to avoid unnecessary requests
-
// and ensure the sub is still valid for this issuer
-
final aud = await _verifyIssuer(tokenSet.sub);
-
-
final now = DateTime.now();
-
-
final tokenResponse = await _request('token', {
-
'grant_type': 'refresh_token',
-
'refresh_token': tokenSet.refreshToken,
-
});
-
-
return TokenSet(
-
aud: aud,
-
sub: tokenSet.sub,
-
iss: issuer,
-
scope: tokenResponse['scope'] as String,
-
refreshToken: tokenResponse['refresh_token'] as String?,
-
accessToken: tokenResponse['access_token'] as String,
-
tokenType: tokenResponse['token_type'] as String,
-
expiresAt:
-
tokenResponse['expires_in'] != null
-
? now
-
.add(Duration(seconds: tokenResponse['expires_in'] as int))
-
.toIso8601String()
-
: null,
-
);
-
}
-
-
/// Verifies that the sub (DID) is indeed issued by this authorization server.
-
///
-
/// This is CRITICAL for security. We must verify that the DID's PDS
-
/// is protected by this authorization server before trusting tokens.
-
///
-
/// Returns the user's PDS URL (the resource server).
-
///
-
/// Throws if:
-
/// - DID resolution fails
-
/// - Issuer mismatch (user may have switched PDS or attack detected)
-
Future<String> _verifyIssuer(String sub) async {
-
final cancelToken = CancelToken();
-
final resolved = await oauthResolver
-
.resolveFromIdentity(
-
sub,
-
GetCachedOptions(
-
noCache: true,
-
allowStale: false,
-
cancelToken: cancelToken,
-
),
-
)
-
.timeout(
-
const Duration(seconds: 10),
-
onTimeout: () {
-
cancelToken.cancel();
-
throw TimeoutException('Issuer verification timed out');
-
},
-
);
-
-
if (issuer != resolved.metadata['issuer']) {
-
// Best case: user switched PDS
-
// Worst case: attack attempt
-
// Either way: MUST NOT allow this token to be used
-
throw FormatException('Issuer mismatch');
-
}
-
-
return resolved.pds.toString();
-
}
-
-
/// Makes a request to an OAuth endpoint (public API).
-
///
-
/// This is a generic method for making OAuth endpoint requests with proper typing.
-
/// Currently supports: token, revocation, pushed_authorization_request.
-
///
-
/// [endpoint] is the endpoint name.
-
/// [payload] is the request body parameters.
-
///
-
/// Returns the parsed JSON response.
-
/// Throws [OAuthResponseError] if the server returns an error.
-
Future<Map<String, dynamic>> request(
-
String endpoint,
-
Map<String, dynamic> payload,
-
) async {
-
return _request(endpoint, payload);
-
}
-
-
/// Makes a request to an OAuth endpoint (internal implementation).
-
///
-
/// [endpoint] is the endpoint name (e.g., 'token', 'revocation', 'pushed_authorization_request').
-
/// [payload] is the request body parameters.
-
///
-
/// Returns the parsed JSON response.
-
/// Throws [OAuthResponseError] if the server returns an error.
-
Future<Map<String, dynamic>> _request(
-
String endpoint,
-
Map<String, dynamic> payload,
-
) async {
-
final url = serverMetadata['${endpoint}_endpoint'];
-
if (url == null) {
-
throw StateError('No $endpoint endpoint available');
-
}
-
-
final auth = await _clientCredentialsFactory();
-
-
final fullPayload = {...payload, ...auth.payload.toJson()};
-
final encodedData = _wwwFormUrlEncode(fullPayload);
-
-
if (kDebugMode && endpoint == 'token') {
-
print('๐ŸŒ Token exchange HTTP request:');
-
print(' โฑ๏ธ Request starting at: ${DateTime.now().toIso8601String()}');
-
print(' URL: $url');
-
print(' Payload keys: ${fullPayload.keys.toList()}');
-
print(' grant_type: ${fullPayload['grant_type']}');
-
print(' client_id: ${fullPayload['client_id']}');
-
print(' redirect_uri: ${fullPayload['redirect_uri']}');
-
print(' code: ${fullPayload['code']?.toString().substring(0, 20)}...');
-
print(
-
' code_verifier: ${fullPayload['code_verifier']?.toString().substring(0, 20)}...',
-
);
-
print(' Headers: ${auth.headers?.keys.toList() ?? []}');
-
}
-
-
try {
-
final response = await _dio.post<Map<String, dynamic>>(
-
url as String,
-
data: encodedData,
-
options: Options(
-
headers: {
-
if (auth.headers != null) ...auth.headers!,
-
'Content-Type': 'application/x-www-form-urlencoded',
-
},
-
),
-
);
-
-
final data = response.data;
-
if (data == null) {
-
throw OAuthResponseError(response, {'error': 'empty_response'});
-
}
-
-
if (kDebugMode && endpoint == 'token') {
-
print(' โœ… Token exchange successful!');
-
}
-
-
return data;
-
} on DioException catch (e) {
-
final response = e.response;
-
if (response != null) {
-
if (kDebugMode && endpoint == 'token') {
-
print(' โŒ Token exchange failed:');
-
print(' Status: ${response.statusCode}');
-
print(' Response: ${response.data}');
-
}
-
throw OAuthResponseError(response, response.data);
-
}
-
rethrow;
-
}
-
}
-
-
/// Encodes a map as application/x-www-form-urlencoded.
-
String _wwwFormUrlEncode(Map<String, dynamic> payload) {
-
final entries = payload.entries
-
.where((e) => e.value != null)
-
.map((e) => MapEntry(e.key, _stringifyValue(e.value)));
-
-
return Uri(queryParameters: Map.fromEntries(entries)).query;
-
}
-
-
/// Converts a value to string for form encoding.
-
String _stringifyValue(dynamic value) {
-
if (value is String) return value;
-
if (value is num) return value.toString();
-
if (value is bool) return value.toString();
-
// For complex types, use JSON encoding
-
return value.toString();
-
}
-
}
-
-
/// Timeout exception.
-
class TimeoutException implements Exception {
-
final String message;
-
TimeoutException(this.message);
-
-
@override
-
String toString() => 'TimeoutException: $message';
-
}
-117
packages/atproto_oauth_flutter/lib/src/oauth/oauth_server_factory.dart
···
-
import 'package:dio/dio.dart';
-
-
import '../runtime/runtime.dart';
-
import '../runtime/runtime_implementation.dart';
-
import '../types.dart';
-
import 'authorization_server_metadata_resolver.dart';
-
import 'client_auth.dart';
-
import 'oauth_resolver.dart';
-
import 'oauth_server_agent.dart';
-
-
/// Factory for creating OAuth server agents.
-
///
-
/// This factory:
-
/// 1. Stores common configuration (client metadata, runtime, resolver, etc.)
-
/// 2. Creates OAuthServerAgent instances for specific issuers
-
/// 3. Handles both new sessions and restored sessions (with legacy support)
-
///
-
/// The factory pattern allows reusing configuration across multiple agents
-
/// and simplifies session restoration.
-
class OAuthServerFactory {
-
final ClientMetadata clientMetadata;
-
final Runtime runtime;
-
final OAuthResolver resolver;
-
final Dio dio;
-
final Keyset? keyset;
-
final DpopNonceCache dpopNonceCache;
-
-
/// Creates a server factory with the given configuration.
-
///
-
/// [clientMetadata] is the validated client metadata.
-
/// [runtime] provides cryptographic operations.
-
/// [resolver] handles OAuth metadata discovery.
-
/// [dio] is the HTTP client.
-
/// [keyset] is optional (only needed for confidential clients).
-
/// [dpopNonceCache] stores DPoP nonces per origin.
-
OAuthServerFactory({
-
required this.clientMetadata,
-
required this.runtime,
-
required this.resolver,
-
required this.dio,
-
this.keyset,
-
required this.dpopNonceCache,
-
});
-
-
/// Creates an OAuth server agent from an issuer URL.
-
///
-
/// This method:
-
/// 1. Fetches authorization server metadata for the issuer
-
/// 2. Uses the provided authMethod or negotiates one (for legacy sessions)
-
/// 3. Creates an OAuthServerAgent with the metadata
-
///
-
/// [issuer] is the authorization server URL.
-
/// [authMethod] is the authentication method to use.
-
/// - For new sessions, pass the result of negotiateClientAuthMethod
-
/// - For legacy sessions (before authMethod was stored), pass 'legacy'
-
/// and the method will be negotiated automatically
-
/// [dpopKey] is the DPoP signing key.
-
/// [options] are optional cache/cancellation options.
-
///
-
/// The 'legacy' authMethod is for backwards compatibility with sessions
-
/// created before we started storing the authMethod. Support for this
-
/// may be removed in the future.
-
///
-
/// Throws [AuthMethodUnsatisfiableError] if auth method cannot be satisfied.
-
Future<OAuthServerAgent> fromIssuer(
-
String issuer,
-
dynamic authMethod, // ClientAuthMethod or 'legacy'
-
Key dpopKey, [
-
GetCachedOptions? options,
-
]) async {
-
final serverMetadata = await resolver.getAuthorizationServerMetadata(
-
issuer,
-
options,
-
);
-
-
ClientAuthMethod finalAuthMethod;
-
if (authMethod == 'legacy') {
-
// Backwards compatibility: compute auth method from metadata
-
finalAuthMethod = negotiateClientAuthMethod(
-
serverMetadata,
-
clientMetadata,
-
keyset,
-
);
-
} else {
-
finalAuthMethod = authMethod as ClientAuthMethod;
-
}
-
-
return fromMetadata(serverMetadata, finalAuthMethod, dpopKey);
-
}
-
-
/// Creates an OAuth server agent from authorization server metadata.
-
///
-
/// This is useful when you already have the metadata cached.
-
///
-
/// [serverMetadata] is the authorization server metadata.
-
/// [authMethod] is the authentication method to use.
-
/// [dpopKey] is the DPoP signing key.
-
///
-
/// Throws [AuthMethodUnsatisfiableError] if auth method cannot be satisfied.
-
OAuthServerAgent fromMetadata(
-
Map<String, dynamic> serverMetadata,
-
ClientAuthMethod authMethod,
-
Key dpopKey,
-
) {
-
return OAuthServerAgent(
-
authMethod: authMethod,
-
dpopKey: dpopKey,
-
serverMetadata: serverMetadata,
-
clientMetadata: clientMetadata,
-
dpopNonces: dpopNonceCache,
-
oauthResolver: resolver,
-
runtime: runtime,
-
keyset: keyset,
-
dio: dio,
-
);
-
}
-
}
-196
packages/atproto_oauth_flutter/lib/src/oauth/protected_resource_metadata_resolver.dart
···
-
import 'package:dio/dio.dart';
-
-
import '../dpop/fetch_dpop.dart';
-
import '../util.dart';
-
import 'authorization_server_metadata_resolver.dart';
-
-
/// Cache interface for protected resource metadata.
-
///
-
/// Implementations should store metadata keyed by origin (scheme://host:port).
-
typedef ProtectedResourceMetadataCache =
-
SimpleStore<String, Map<String, dynamic>>;
-
-
/// Configuration for the protected resource metadata resolver.
-
class OAuthProtectedResourceMetadataResolverConfig {
-
/// Whether to allow HTTP (non-HTTPS) resource URLs.
-
///
-
/// Should only be true in development/test environments.
-
/// Production MUST use HTTPS.
-
final bool allowHttpResource;
-
-
const OAuthProtectedResourceMetadataResolverConfig({
-
this.allowHttpResource = false,
-
});
-
}
-
-
/// Resolves OAuth Protected Resource Metadata via RFC 9728 discovery.
-
///
-
/// This class:
-
/// 1. Validates resource URLs (must be HTTPS in production)
-
/// 2. Fetches metadata from `{origin}/.well-known/oauth-protected-resource`
-
/// 3. Validates the metadata against the spec
-
/// 4. Verifies resource field matches origin
-
/// 5. Caches metadata to avoid repeated fetches
-
///
-
/// See: https://www.rfc-editor.org/rfc/rfc9728.html
-
class OAuthProtectedResourceMetadataResolver {
-
final ProtectedResourceMetadataCache _cache;
-
final Dio _dio;
-
final bool _allowHttpResource;
-
-
/// Creates a resolver with the given cache and HTTP client.
-
///
-
/// [cache] is used to store fetched metadata. Use an in-memory store for
-
/// testing or a persistent store for production.
-
///
-
/// [dio] is the HTTP client. If not provided, creates a default instance.
-
///
-
/// [config] allows customizing behavior (e.g., allowing HTTP in tests).
-
OAuthProtectedResourceMetadataResolver(
-
this._cache, {
-
Dio? dio,
-
OAuthProtectedResourceMetadataResolverConfig? config,
-
}) : _dio = dio ?? Dio(),
-
_allowHttpResource = config?.allowHttpResource ?? false;
-
-
/// Resolves protected resource metadata for the given resource URL.
-
///
-
/// The [resource] can be a String URL or Uri. Only the origin is used.
-
///
-
/// Returns the complete metadata as a Map. Throws if:
-
/// - Resource is not a valid URL
-
/// - Protocol is not HTTP/HTTPS
-
/// - HTTP is used in production (allowHttpResource = false)
-
/// - Network request fails
-
/// - Response is not valid JSON
-
/// - Metadata validation fails
-
/// - Resource mismatch detected
-
///
-
/// Example:
-
/// ```dart
-
/// final resolver = OAuthProtectedResourceMetadataResolver(cache);
-
/// final metadata = await resolver.get('https://pds.example.com');
-
/// print(metadata['authorization_servers']);
-
/// ```
-
Future<Map<String, dynamic>> get(
-
dynamic resource, [
-
GetCachedOptions? options,
-
]) async {
-
// Parse URL and extract origin
-
final uri = resource is Uri ? resource : Uri.parse(resource.toString());
-
final protocol = uri.scheme;
-
final origin =
-
'${uri.scheme}://${uri.host}${uri.hasPort ? ':${uri.port}' : ''}';
-
-
// Validate protocol
-
if (protocol != 'https' && protocol != 'http') {
-
throw FormatException(
-
'Invalid protected resource metadata URL protocol: $protocol',
-
);
-
}
-
-
// Security check: disallow HTTP in production
-
if (protocol == 'http' && !_allowHttpResource) {
-
throw FormatException(
-
'Unsecure resource metadata URL ($protocol) only allowed in development and test environments',
-
);
-
}
-
-
// Check cache first (unless noCache is set)
-
if (options?.noCache != true) {
-
final cached = await _cache.get(origin);
-
if (cached != null) {
-
return cached;
-
}
-
}
-
-
// Fetch fresh metadata
-
final metadata = await _fetchMetadata(origin, options);
-
-
// Store in cache
-
await _cache.set(origin, metadata);
-
-
return metadata;
-
}
-
-
/// Fetches metadata from the well-known endpoint.
-
Future<Map<String, dynamic>> _fetchMetadata(
-
String origin,
-
GetCachedOptions? options,
-
) async {
-
final url =
-
Uri.parse(
-
origin,
-
).replace(path: '/.well-known/oauth-protected-resource').toString();
-
-
try {
-
final response = await _dio.get<Map<String, dynamic>>(
-
url,
-
options: Options(
-
headers: {'accept': 'application/json'},
-
followRedirects: false, // response must be 200 OK, no redirects
-
validateStatus: (status) => status == 200,
-
),
-
cancelToken: options?.cancelToken,
-
);
-
-
// Verify content type
-
final contentType = contentMime(
-
response.headers.map.map((key, value) => MapEntry(key, value.first)),
-
);
-
-
if (contentType != 'application/json') {
-
throw DioException(
-
requestOptions: response.requestOptions,
-
response: response,
-
type: DioExceptionType.badResponse,
-
message: 'Unexpected content type for "$url"',
-
);
-
}
-
-
final metadata = response.data;
-
if (metadata == null) {
-
throw DioException(
-
requestOptions: response.requestOptions,
-
response: response,
-
type: DioExceptionType.badResponse,
-
message: 'Empty response body for "$url"',
-
);
-
}
-
-
// Validate metadata
-
_validateMetadata(metadata, origin);
-
-
return metadata;
-
} on DioException catch (e) {
-
if (e.response?.statusCode == 200) {
-
// Already handled above, rethrow
-
rethrow;
-
}
-
throw DioException(
-
requestOptions: e.requestOptions,
-
response: e.response,
-
type: e.type,
-
message:
-
'Unexpected status code ${e.response?.statusCode ?? 'unknown'} for "$url"',
-
error: e.error,
-
);
-
}
-
}
-
-
/// Validates protected resource metadata.
-
///
-
/// Checks:
-
/// - Resource field matches the expected origin
-
/// - Authorization servers list is present
-
void _validateMetadata(Map<String, dynamic> metadata, String expectedOrigin) {
-
// Validate resource field
-
// https://www.rfc-editor.org/rfc/rfc9728.html#section-3.3
-
final resource = metadata['resource'];
-
if (resource != expectedOrigin) {
-
throw FormatException(
-
'Invalid resource: expected "$expectedOrigin", got "$resource"',
-
);
-
}
-
}
-
}
-213
packages/atproto_oauth_flutter/lib/src/oauth/validate_client_metadata.dart
···
-
import '../constants.dart';
-
import '../types.dart';
-
import 'client_auth.dart';
-
-
/// Validates client metadata for OAuth compliance.
-
///
-
/// This function performs comprehensive validation of client metadata to ensure:
-
/// 1. Client ID is valid (either discoverable HTTPS or loopback)
-
/// 2. Required ATPROTO scope is present
-
/// 3. Required response_types and grant_types are present
-
/// 4. Authentication method is properly configured
-
/// 5. For private_key_jwt, keyset and JWKS are properly configured
-
///
-
/// The validation enforces ATPROTO OAuth requirements on top of standard OAuth.
-
///
-
/// Returns the validated ClientMetadata.
-
/// Throws TypeError if validation fails.
-
ClientMetadata validateClientMetadata(
-
Map<String, dynamic> input,
-
Keyset? keyset,
-
) {
-
// Allow passing a keyset and omitting jwks/jwks_uri
-
// The keyset will be serialized into the metadata
-
Map<String, dynamic> enrichedInput = input;
-
if (input['jwks'] == null &&
-
input['jwks_uri'] == null &&
-
keyset != null &&
-
keyset.size > 0) {
-
enrichedInput = {...input, 'jwks': keyset.toJSON()};
-
}
-
-
// Parse into ClientMetadata
-
final metadata = ClientMetadata.fromJson(enrichedInput);
-
-
// Validate client ID
-
final clientId = metadata.clientId;
-
if (clientId == null) {
-
throw FormatException('Client metadata must include client_id');
-
}
-
-
if (clientId.startsWith('http:')) {
-
// Loopback client ID (for development)
-
_assertOAuthLoopbackClientId(clientId);
-
} else {
-
// Discoverable client ID (production)
-
_assertOAuthDiscoverableClientId(clientId);
-
}
-
-
// Validate scope includes "atproto"
-
final scopes = metadata.scope?.split(' ') ?? [];
-
if (!scopes.contains('atproto')) {
-
throw FormatException('Client metadata must include the "atproto" scope');
-
}
-
-
// Validate response_types
-
if (!metadata.responseTypes.contains('code')) {
-
throw FormatException('"response_types" must include "code"');
-
}
-
-
// Validate grant_types
-
if (!metadata.grantTypes.contains('authorization_code')) {
-
throw FormatException('"grant_types" must include "authorization_code"');
-
}
-
-
// Validate authentication method
-
final method = metadata.tokenEndpointAuthMethod;
-
final methodAlg = metadata.tokenEndpointAuthSigningAlg;
-
-
switch (method) {
-
case 'none':
-
if (methodAlg != null) {
-
throw FormatException(
-
'"token_endpoint_auth_signing_alg" must not be provided when '
-
'"token_endpoint_auth_method" is "$method"',
-
);
-
}
-
break;
-
-
case 'private_key_jwt':
-
if (methodAlg == null) {
-
throw FormatException(
-
'"token_endpoint_auth_signing_alg" must be provided when '
-
'"token_endpoint_auth_method" is "$method"',
-
);
-
}
-
-
if (keyset == null) {
-
throw FormatException(
-
'Client authentication method "$method" requires a keyset',
-
);
-
}
-
-
// Validate signing keys
-
final signingKeys = keyset.keys.where((key) => key.kid != null).toList();
-
-
if (signingKeys.isEmpty) {
-
throw FormatException(
-
'Client authentication method "$method" requires at least one '
-
'active signing key with a "kid" property',
-
);
-
}
-
-
if (!signingKeys.any((key) => key.algorithms.contains(fallbackAlg))) {
-
throw FormatException(
-
'Client authentication method "$method" requires at least one '
-
'active "$fallbackAlg" signing key',
-
);
-
}
-
-
// Validate JWKS
-
if (metadata.jwks != null) {
-
// Ensure all signing keys are in the JWKS
-
final jwksKeys = (metadata.jwks!['keys'] as List?) ?? [];
-
for (final key in signingKeys) {
-
final found = jwksKeys.any((k) {
-
if (k is! Map<String, dynamic>) return false;
-
final revoked = k['revoked'] as bool?;
-
return k['kid'] == key.kid && revoked != true;
-
});
-
-
if (!found) {
-
throw FormatException(
-
'Missing or inactive key "${key.kid}" in jwks. '
-
'Make sure that every signing key of the Keyset is declared as '
-
'an active key in the Metadata\'s JWKS.',
-
);
-
}
-
}
-
} else if (metadata.jwksUri != null) {
-
// JWKS URI is acceptable, but we can't validate it here
-
// (we don't want to download the file during validation)
-
} else {
-
throw FormatException(
-
'Client authentication method "$method" requires a JWKS',
-
);
-
}
-
break;
-
-
default:
-
throw FormatException(
-
'Unsupported "token_endpoint_auth_method" value: $method',
-
);
-
}
-
-
return metadata;
-
}
-
-
/// Validates that a client ID is a valid discoverable client ID.
-
///
-
/// A discoverable client ID must be an HTTPS URL that can be dereferenced
-
/// to get the client metadata document.
-
///
-
/// See: https://datatracker.ietf.org/doc/draft-ietf-oauth-client-id-metadata-document/
-
void _assertOAuthDiscoverableClientId(String clientId) {
-
final uri = Uri.tryParse(clientId);
-
-
if (uri == null) {
-
throw FormatException('Invalid client_id URL: $clientId');
-
}
-
-
if (uri.scheme != 'https') {
-
throw FormatException('Discoverable client_id must use HTTPS: $clientId');
-
}
-
-
if (uri.hasFragment) {
-
throw FormatException(
-
'Discoverable client_id must not contain a fragment: $clientId',
-
);
-
}
-
-
// Validate it's a valid URL
-
if (!uri.hasAuthority) {
-
throw FormatException('Invalid discoverable client_id URL: $clientId');
-
}
-
}
-
-
/// Validates that a client ID is a valid loopback client ID.
-
///
-
/// A loopback client ID is used for development/testing and must be:
-
/// - An HTTP URL (not HTTPS)
-
/// - Using localhost or 127.0.0.1
-
/// - Optionally with a port
-
///
-
/// See: https://datatracker.ietf.org/doc/html/rfc8252#section-7.3
-
void _assertOAuthLoopbackClientId(String clientId) {
-
final uri = Uri.tryParse(clientId);
-
-
if (uri == null) {
-
throw FormatException('Invalid client_id URL: $clientId');
-
}
-
-
if (uri.scheme != 'http') {
-
throw FormatException(
-
'Loopback client_id must use HTTP (not HTTPS): $clientId',
-
);
-
}
-
-
final host = uri.host.toLowerCase();
-
if (host != 'localhost' &&
-
host != '127.0.0.1' &&
-
host != '[::1]' &&
-
host != '::1') {
-
throw FormatException(
-
'Loopback client_id must use localhost or 127.0.0.1: $clientId',
-
);
-
}
-
-
if (uri.hasFragment) {
-
throw FormatException(
-
'Loopback client_id must not contain a fragment: $clientId',
-
);
-
}
-
}
-330
packages/atproto_oauth_flutter/lib/src/platform/README.md
···
-
# Flutter Platform Layer
-
-
This directory contains Flutter-specific implementations of the atproto OAuth client.
-
-
## Overview
-
-
The platform layer provides concrete implementations of all the abstract interfaces needed for OAuth to work on Flutter:
-
-
1. **Storage** (`flutter_stores.dart`) - Secure session storage and caching
-
2. **Cryptography** (`flutter_runtime.dart`) - Key generation, hashing, random values
-
3. **Key Management** (`flutter_key.dart`) - EC key implementation with pointycastle
-
4. **High-level API** (`flutter_oauth_client.dart`) - Easy-to-use Flutter OAuth client
-
-
## Architecture
-
-
```
-
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
-
โ”‚ FlutterOAuthClient โ”‚
-
โ”‚ (High-level API) โ”‚
-
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
-
โ”‚
-
โ–ผ
-
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
-
โ”‚ OAuthClient โ”‚
-
โ”‚ (Core OAuth logic) โ”‚
-
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
-
โ”‚
-
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
-
โ–ผ โ–ผ โ–ผ
-
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
-
โ”‚ Storage โ”‚ โ”‚ Runtime โ”‚ โ”‚ Key โ”‚
-
โ”‚ (secure โ”‚ โ”‚ (crypto) โ”‚ โ”‚ (signing) โ”‚
-
โ”‚ storage) โ”‚ โ”‚ โ”‚ โ”‚ โ”‚
-
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
-
โ”‚ โ”‚ โ”‚
-
โ–ผ โ–ผ โ–ผ
-
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
-
โ”‚ flutter_ โ”‚ โ”‚ crypto/ โ”‚ โ”‚ pointycastleโ”‚
-
โ”‚ secure_ โ”‚ โ”‚ Random. โ”‚ โ”‚ (ECDSA) โ”‚
-
โ”‚ storage โ”‚ โ”‚ secure() โ”‚ โ”‚ โ”‚
-
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
-
```
-
-
## Files
-
-
### `flutter_stores.dart`
-
-
Implements storage and caching:
-
-
- **FlutterSessionStore**: Persists OAuth sessions in secure storage
-
- iOS: Keychain
-
- Android: EncryptedSharedPreferences
-
- Stores tokens, DPoP keys, and auth methods
-
-
- **FlutterStateStore**: Ephemeral OAuth state (in-memory)
-
- PKCE verifiers
-
- State parameters
-
- Application state
-
-
- **Cache Implementations**: In-memory caches with TTL
-
- `InMemoryAuthorizationServerMetadataCache`: OAuth server metadata (1 min TTL)
-
- `InMemoryProtectedResourceMetadataCache`: Resource server metadata (1 min TTL)
-
- `InMemoryDpopNonceCache`: DPoP nonces (10 min TTL)
-
- `FlutterDidCache`: DID documents (1 min TTL)
-
- `FlutterHandleCache`: Handle โ†’ DID mappings (1 min TTL)
-
-
### `flutter_runtime.dart`
-
-
Implements cryptographic operations:
-
-
- **FlutterRuntime**: Platform-specific crypto implementation
-
- `createKey`: EC key generation (ES256/ES384/ES512/ES256K)
-
- `digest`: SHA-256/384/512 hashing
-
- `getRandomValues`: Cryptographically secure random bytes
-
- `requestLock`: Local (in-memory) locking for token refresh
-
-
Uses:
-
- `crypto` package for SHA hashing
-
- `Random.secure()` for randomness
-
- `utils/lock.dart` for concurrency control
-
-
### `flutter_key.dart`
-
-
Implements EC key management:
-
-
- **FlutterKey**: Elliptic Curve key for JWT signing
-
- Supports ES256, ES384, ES512, ES256K
-
- Uses `pointycastle` for ECDSA operations
-
- Implements `Key` interface from runtime layer
-
- Serializable (for session storage)
-
-
Features:
-
- Secure key generation with `FortunaRandom`
-
- JWT signing (compact format)
-
- JWK representation (public and private)
-
- Key reconstruction from JSON
-
-
### `flutter_oauth_client.dart`
-
-
High-level Flutter API:
-
-
- **FlutterOAuthClient**: Easy-to-use OAuth client
-
- Pre-configured storage and caching
-
- Automatic FlutterWebAuth2 integration
-
- Simplified sign-in flow
-
- Session management helpers
-
-
Key method:
-
```dart
-
// One-liner sign in!
-
final session = await client.signIn('alice.bsky.social');
-
```
-
-
This handles:
-
1. Authorization URL generation
-
2. Browser launch (FlutterWebAuth2)
-
3. Callback handling
-
4. Token exchange
-
5. Session storage
-
-
## Security Features
-
-
### 1. Secure Storage
-
-
Tokens are **never** stored in plain text:
-
-
- **iOS**: Stored in Keychain with device encryption
-
- **Android**: EncryptedSharedPreferences with AES-256
-
-
### 2. DPoP (Demonstrating Proof of Possession)
-
-
Tokens are cryptographically bound to EC keys:
-
-
- Prevents token theft (stolen tokens are useless without the key)
-
- Keys stored alongside tokens in secure storage
-
- Every API request includes a signed DPoP proof
-
-
### 3. PKCE (Proof Key for Code Exchange)
-
-
Protects authorization codes from interception:
-
-
- Random code verifier generated for each flow
-
- Challenge sent to server (SHA-256 hash of verifier)
-
- Verifier required to exchange code for tokens
-
-
### 4. Concurrency Control
-
-
Prevents race conditions in token refresh:
-
-
- Local lock ensures only one refresh at a time
-
- Reduces chances of using refresh token twice
-
- Handles concurrent requests gracefully
-
-
### 5. Automatic Cleanup
-
-
Sessions are automatically deleted on errors:
-
-
- Token refresh failures
-
- Invalid token errors
-
- Auth method unsatisfiable errors
-
- Revocation (local and remote)
-
-
## Usage
-
-
### Basic Usage
-
-
```dart
-
import 'package:atproto_oauth_flutter/atproto_oauth_flutter.dart';
-
-
// Initialize
-
final client = FlutterOAuthClient(
-
clientMetadata: ClientMetadata(
-
clientId: 'https://example.com/client-metadata.json',
-
redirectUris: ['myapp://oauth/callback'],
-
),
-
);
-
-
// Sign in
-
final session = await client.signIn('alice.bsky.social');
-
-
// Use session
-
print('Signed in as: ${session.sub}');
-
-
// Restore later
-
final restored = await client.restore(session.sub);
-
-
// Sign out
-
await client.revoke(session.sub);
-
```
-
-
### Custom Configuration
-
-
```dart
-
final client = FlutterOAuthClient(
-
clientMetadata: ClientMetadata(
-
clientId: 'https://example.com/client-metadata.json',
-
redirectUris: ['myapp://oauth/callback'],
-
),
-
-
// Custom secure storage
-
secureStorage: FlutterSecureStorage(
-
aOptions: AndroidOptions(
-
encryptedSharedPreferences: true,
-
),
-
),
-
-
// Development mode
-
allowHttp: true,
-
-
// Custom PLC directory
-
plcDirectoryUrl: 'https://plc.example.com',
-
);
-
```
-
-
## Testing
-
-
The platform layer is designed to be testable:
-
-
1. **Mock Storage**: Provide test implementation of `SessionStore`
-
2. **Mock Runtime**: Provide test implementation of `RuntimeImplementation`
-
3. **Mock Keys**: Use fixed test keys instead of random generation
-
-
Example:
-
-
```dart
-
// Test storage that uses in-memory map
-
class TestSessionStore implements SessionStore {
-
final Map<String, Session> _store = {};
-
-
@override
-
Future<Session?> get(String key, {CancellationToken? signal}) async {
-
return _store[key];
-
}
-
-
@override
-
Future<void> set(String key, Session value) async {
-
_store[key] = value;
-
}
-
-
// ... etc
-
}
-
-
// Use in tests
-
final client = OAuthClient(
-
OAuthClientOptions(
-
// ... other options
-
sessionStore: TestSessionStore(),
-
),
-
);
-
```
-
-
## Platform Setup
-
-
### iOS
-
-
Add URL scheme to `Info.plist`:
-
-
```xml
-
<key>CFBundleURLTypes</key>
-
<array>
-
<dict>
-
<key>CFBundleURLSchemes</key>
-
<array>
-
<string>myapp</string>
-
</array>
-
</dict>
-
</array>
-
```
-
-
### Android
-
-
Add intent filter to `AndroidManifest.xml`:
-
-
```xml
-
<intent-filter>
-
<action android:name="android.intent.action.VIEW" />
-
<category android:name="android.intent.category.DEFAULT" />
-
<category android:name="android.intent.category.BROWSABLE" />
-
<data android:scheme="myapp" />
-
</intent-filter>
-
```
-
-
## Dependencies
-
-
- `flutter_secure_storage: ^9.2.2` - Secure token storage
-
- `flutter_web_auth_2: ^4.1.0` - Browser-based OAuth flow
-
- `pointycastle: ^3.9.1` - Elliptic Curve cryptography
-
- `crypto: ^3.0.3` - SHA hashing
-
-
## Known Limitations
-
-
### 1. Key Serialization
-
-
Currently, DPoP keys are regenerated on each app restart. This works but has drawbacks:
-
-
- Tokens bound to old keys become invalid (require refresh)
-
- Slight performance impact on session restoration
-
-
**Fix**: Implement proper `Key` serialization in `flutter_key.dart`:
-
- Add `toJson()` method that includes private key components
-
- Add `fromJson()` factory that reconstructs the key
-
- Store serialized keys in session storage
-
-
### 2. Local Lock Only
-
-
The lock implementation is in-memory and doesn't work across:
-
- Multiple isolates
-
- Multiple processes
-
- Multiple app instances
-
-
For most Flutter apps, this is fine. For advanced use cases, implement a platform-specific lock.
-
-
### 3. Cache TTLs
-
-
Cache TTLs are fixed (1 minute for most caches). Consider making these configurable if your app has different caching requirements.
-
-
## Future Improvements
-
-
1. **Key Persistence**: Implement proper key serialization (see above)
-
2. **Platform Locks**: Add iOS/Android native lock implementations
-
3. **Configurable TTLs**: Allow cache TTL customization
-
4. **Background Refresh**: Support token refresh in background
-
5. **Biometric Auth**: Optional biometric unlock for sessions
-
6. **Migration Helpers**: Tools for migrating from other OAuth libraries
-
-
## See Also
-
-
- [Example usage](../../example/flutter_oauth_example.dart)
-
- [Main library docs](../../atproto_oauth_flutter.dart)
-
- [Core OAuth client](../client/oauth_client.dart)
-435
packages/atproto_oauth_flutter/lib/src/platform/flutter_key.dart
···
-
import 'dart:convert';
-
import 'dart:math';
-
import 'dart:typed_data';
-
-
import 'package:pointycastle/export.dart' as pointycastle;
-
-
import '../runtime/runtime_implementation.dart';
-
-
/// Flutter implementation of Key using pointycastle for cryptographic operations.
-
///
-
/// Supports EC keys with the following algorithms:
-
/// - ES256 (P-256/secp256r1)
-
/// - ES384 (P-384/secp384r1)
-
/// - ES512 (P-521/secp521r1) - Note: P-521, not P-512
-
/// - ES256K (secp256k1)
-
///
-
/// This class handles:
-
/// - Key generation with secure randomness
-
/// - JWT signing (ES256/ES384/ES512/ES256K)
-
/// - JWK representation (public and private components)
-
/// - Serialization/deserialization for session storage
-
class FlutterKey implements Key {
-
/// The EC private key (contains both private and public components)
-
final pointycastle.ECPrivateKey privateKey;
-
-
/// The EC public key
-
final pointycastle.ECPublicKey publicKey;
-
-
/// The algorithm this key supports
-
final String algorithm;
-
-
/// Optional key ID
-
final String? _kid;
-
-
/// Creates a FlutterKey from EC key components.
-
FlutterKey({
-
required this.privateKey,
-
required this.publicKey,
-
required this.algorithm,
-
String? kid,
-
}) : _kid = kid;
-
-
@override
-
List<String> get algorithms => [algorithm];
-
-
@override
-
String? get kid => _kid;
-
-
@override
-
String get usage => 'sign';
-
-
@override
-
Map<String, dynamic>? get bareJwk {
-
// Return public key components only (no private key 'd')
-
final jwk = _ecPublicKeyToJwk(publicKey, algorithm);
-
if (_kid != null) {
-
jwk['kid'] = _kid;
-
}
-
return jwk;
-
}
-
-
/// Full JWK including private key components.
-
///
-
/// WARNING: This contains sensitive key material. Never log or expose.
-
/// Only use for secure storage.
-
Map<String, dynamic> get privateJwk {
-
final jwk = _ecPrivateKeyToJwk(privateKey, publicKey, algorithm);
-
if (_kid != null) {
-
jwk['kid'] = _kid;
-
}
-
return jwk;
-
}
-
-
@override
-
Future<String> createJwt(
-
Map<String, dynamic> header,
-
Map<String, dynamic> payload,
-
) async {
-
// Build JWT header
-
final jwtHeader = <String, dynamic>{
-
'typ': 'JWT',
-
'alg': algorithm,
-
...header,
-
};
-
if (_kid != null) {
-
jwtHeader['kid'] = _kid;
-
}
-
-
// Encode header and payload
-
final headerB64 = _base64UrlEncode(utf8.encode(json.encode(jwtHeader)));
-
final payloadB64 = _base64UrlEncode(utf8.encode(json.encode(payload)));
-
-
// Create signing input
-
final signingInput = '$headerB64.$payloadB64';
-
final signingBytes = utf8.encode(signingInput);
-
-
// Sign with appropriate algorithm
-
final signature = _signEcdsa(signingBytes, privateKey, algorithm);
-
-
// Encode signature
-
final signatureB64 = _base64UrlEncode(signature);
-
-
// Return compact JWT
-
return '$signingInput.$signatureB64';
-
}
-
-
/// Generates a new FlutterKey for the given algorithms.
-
///
-
/// Returns a key supporting the first compatible algorithm from the list.
-
///
-
/// Throws [UnsupportedError] if no compatible algorithm is found.
-
static Future<FlutterKey> generate(List<String> algs) async {
-
// Try algorithms in order
-
for (final alg in algs) {
-
switch (alg) {
-
case 'ES256':
-
return _generateECKey('ES256', 'P-256');
-
case 'ES384':
-
return _generateECKey('ES384', 'P-384');
-
case 'ES512':
-
return _generateECKey('ES512', 'P-521'); // Note: P-521, not P-512
-
case 'ES256K':
-
return _generateECKey('ES256K', 'secp256k1');
-
}
-
}
-
-
throw UnsupportedError(
-
'No supported algorithm found in: ${algs.join(", ")}',
-
);
-
}
-
-
/// Reconstructs a FlutterKey from serialized JWK data.
-
///
-
/// This is used when restoring sessions from storage.
-
factory FlutterKey.fromJwk(Map<String, dynamic> jwk) {
-
final kty = jwk['kty'] as String?;
-
if (kty != 'EC') {
-
throw FormatException('Unsupported key type: $kty');
-
}
-
-
final crv = jwk['crv'] as String?;
-
final alg = jwk['alg'] as String?;
-
final kid = jwk['kid'] as String?;
-
-
if (crv == null || alg == null) {
-
throw FormatException('Missing required JWK fields');
-
}
-
-
// Parse key components
-
final x = _base64UrlDecode(jwk['x'] as String);
-
final y = _base64UrlDecode(jwk['y'] as String);
-
final d = jwk['d'] != null ? _base64UrlDecode(jwk['d'] as String) : null;
-
-
if (d == null) {
-
throw FormatException('Private key component (d) is required');
-
}
-
-
// Get curve
-
final curve = _getCurveForName(crv);
-
-
// Reconstruct public key
-
final publicKey = pointycastle.ECPublicKey(
-
curve.curve.createPoint(_bytesToBigInt(x), _bytesToBigInt(y)),
-
curve,
-
);
-
-
// Reconstruct private key
-
final privateKey = pointycastle.ECPrivateKey(_bytesToBigInt(d), curve);
-
-
return FlutterKey(
-
privateKey: privateKey,
-
publicKey: publicKey,
-
algorithm: alg,
-
kid: kid,
-
);
-
}
-
-
/// Serializes this key to JSON (for session storage).
-
///
-
/// WARNING: Contains private key material. Store securely.
-
Map<String, dynamic> toJson() => privateJwk;
-
-
// ============================================================================
-
// Private helper methods
-
// ============================================================================
-
-
/// Generates an EC key pair for the given algorithm and curve.
-
static Future<FlutterKey> _generateECKey(
-
String algorithm,
-
String curveName,
-
) async {
-
final curve = _getCurveForName(curveName);
-
-
// Create secure random generator
-
final secureRandom = pointycastle.FortunaRandom();
-
final random = Random.secure();
-
final seeds = List<int>.generate(32, (_) => random.nextInt(256));
-
secureRandom.seed(pointycastle.KeyParameter(Uint8List.fromList(seeds)));
-
-
// Generate key pair
-
final keyGen = pointycastle.ECKeyGenerator();
-
keyGen.init(
-
pointycastle.ParametersWithRandom(
-
pointycastle.ECKeyGeneratorParameters(curve),
-
secureRandom,
-
),
-
);
-
-
final keyPair = keyGen.generateKeyPair();
-
final privateKey = keyPair.privateKey as pointycastle.ECPrivateKey;
-
final publicKey = keyPair.publicKey as pointycastle.ECPublicKey;
-
-
return FlutterKey(
-
privateKey: privateKey,
-
publicKey: publicKey,
-
algorithm: algorithm,
-
);
-
}
-
-
/// Gets the EC domain parameters for a given curve name.
-
static pointycastle.ECDomainParameters _getCurveForName(String name) {
-
// Use pointycastle's standard curve implementations
-
switch (name) {
-
case 'P-256':
-
case 'prime256v1':
-
case 'secp256r1':
-
return pointycastle.ECCurve_secp256r1();
-
case 'P-384':
-
case 'secp384r1':
-
return pointycastle.ECCurve_secp384r1();
-
case 'P-521':
-
case 'secp521r1':
-
return pointycastle.ECCurve_secp521r1();
-
case 'secp256k1':
-
return pointycastle.ECCurve_secp256k1();
-
default:
-
throw UnsupportedError('Unsupported curve: $name');
-
}
-
}
-
-
/// Gets the curve name for JWK representation.
-
static String _getCurveName(String algorithm) {
-
switch (algorithm) {
-
case 'ES256':
-
return 'P-256';
-
case 'ES384':
-
return 'P-384';
-
case 'ES512':
-
return 'P-521';
-
case 'ES256K':
-
return 'secp256k1';
-
default:
-
throw UnsupportedError('Unsupported algorithm: $algorithm');
-
}
-
}
-
-
/// Gets the hash algorithm for signing.
-
static String _getHashAlgorithm(String algorithm) {
-
switch (algorithm) {
-
case 'ES256':
-
case 'ES256K':
-
return 'SHA-256';
-
case 'ES384':
-
return 'SHA-384';
-
case 'ES512':
-
return 'SHA-512';
-
default:
-
throw UnsupportedError('Unsupported algorithm: $algorithm');
-
}
-
}
-
-
/// Signs data using ECDSA with deterministic signatures (RFC 6979).
-
///
-
/// This uses deterministic ECDSA which doesn't require a source of randomness,
-
/// making it more secure and avoiding SecureRandom initialization issues.
-
static Uint8List _signEcdsa(
-
List<int> data,
-
pointycastle.ECPrivateKey privateKey,
-
String algorithm,
-
) {
-
// Get the appropriate hash algorithm for this signing algorithm
-
final hashAlg = _getHashAlgorithm(algorithm);
-
-
// Build deterministic ECDSA signer name (e.g., "SHA-256/DET-ECDSA")
-
final signerName = '$hashAlg/DET-ECDSA';
-
-
// Use deterministic ECDSA signer (RFC 6979) - no randomness required!
-
final signer = pointycastle.Signer(signerName);
-
signer.init(
-
true, // signing mode
-
pointycastle.PrivateKeyParameter<pointycastle.ECPrivateKey>(privateKey),
-
);
-
-
// Sign the data (signer will hash it internally)
-
final signature =
-
signer.generateSignature(Uint8List.fromList(data))
-
as pointycastle.ECSignature;
-
-
// Encode as IEEE P1363 format (r || s)
-
final r = _bigIntToBytes(signature.r, _getSignatureLength(algorithm));
-
final s = _bigIntToBytes(signature.s, _getSignatureLength(algorithm));
-
-
return Uint8List.fromList([...r, ...s]);
-
}
-
-
/// Creates a pointycastle Digest for the given hash algorithm.
-
static pointycastle.Digest _createDigest(String algorithm) {
-
switch (algorithm) {
-
case 'SHA-256':
-
return pointycastle.SHA256Digest();
-
case 'SHA-384':
-
return pointycastle.SHA384Digest();
-
case 'SHA-512':
-
return pointycastle.SHA512Digest();
-
default:
-
throw UnsupportedError('Unsupported hash: $algorithm');
-
}
-
}
-
-
/// Gets the signature length in bytes for the algorithm.
-
static int _getSignatureLength(String algorithm) {
-
switch (algorithm) {
-
case 'ES256':
-
case 'ES256K':
-
return 32;
-
case 'ES384':
-
return 48;
-
case 'ES512':
-
return 66; // P-521 uses 66 bytes per component
-
default:
-
throw UnsupportedError('Unsupported algorithm: $algorithm');
-
}
-
}
-
-
/// Converts an EC public key to JWK format.
-
static Map<String, dynamic> _ecPublicKeyToJwk(
-
pointycastle.ECPublicKey publicKey,
-
String algorithm,
-
) {
-
final q = publicKey.Q!;
-
final curve = _getCurveName(algorithm);
-
-
return {
-
'kty': 'EC',
-
'crv': curve,
-
'x': _base64UrlEncode(_bigIntToBytes(q.x!.toBigInteger()!)),
-
'y': _base64UrlEncode(_bigIntToBytes(q.y!.toBigInteger()!)),
-
'alg': algorithm,
-
'use': 'sig',
-
'key_ops': ['sign'],
-
};
-
}
-
-
/// Converts an EC private key to JWK format (includes private component).
-
static Map<String, dynamic> _ecPrivateKeyToJwk(
-
pointycastle.ECPrivateKey privateKey,
-
pointycastle.ECPublicKey publicKey,
-
String algorithm,
-
) {
-
final jwk = _ecPublicKeyToJwk(publicKey, algorithm);
-
jwk['d'] = _base64UrlEncode(_bigIntToBytes(privateKey.d!));
-
return jwk;
-
}
-
-
/// Converts a BigInt to bytes with optional padding.
-
static Uint8List _bigIntToBytes(BigInt number, [int? length]) {
-
var bytes = _encodeBigInt(number);
-
-
if (length != null) {
-
if (bytes.length > length) {
-
// Remove leading zeros
-
bytes = bytes.sublist(bytes.length - length);
-
} else if (bytes.length < length) {
-
// Add leading zeros
-
final padded = Uint8List(length);
-
padded.setRange(length - bytes.length, length, bytes);
-
bytes = padded;
-
}
-
}
-
-
return bytes;
-
}
-
-
/// Encodes a BigInt as bytes (unsigned, big-endian).
-
static Uint8List _encodeBigInt(BigInt number) {
-
// Handle zero
-
if (number == BigInt.zero) {
-
return Uint8List.fromList([0]);
-
}
-
-
// Handle negative (should not happen for EC keys)
-
if (number.isNegative) {
-
throw ArgumentError('Cannot encode negative BigInt');
-
}
-
-
// Convert to bytes
-
final bytes = <int>[];
-
var n = number;
-
while (n > BigInt.zero) {
-
bytes.insert(0, (n & BigInt.from(0xff)).toInt());
-
n = n >> 8;
-
}
-
-
return Uint8List.fromList(bytes);
-
}
-
-
/// Converts bytes to BigInt (unsigned, big-endian).
-
static BigInt _bytesToBigInt(List<int> bytes) {
-
var result = BigInt.zero;
-
for (var byte in bytes) {
-
result = (result << 8) | BigInt.from(byte);
-
}
-
return result;
-
}
-
-
/// Base64url encodes bytes (no padding).
-
static String _base64UrlEncode(List<int> bytes) {
-
return base64Url.encode(bytes).replaceAll('=', '');
-
}
-
-
/// Base64url decodes a string.
-
static Uint8List _base64UrlDecode(String str) {
-
// Add padding if needed
-
var s = str;
-
switch (s.length % 4) {
-
case 2:
-
s += '==';
-
break;
-
case 3:
-
s += '=';
-
break;
-
}
-
return base64Url.decode(s);
-
}
-
}
-302
packages/atproto_oauth_flutter/lib/src/platform/flutter_oauth_client.dart
···
-
import 'package:dio/dio.dart';
-
import 'package:flutter/foundation.dart';
-
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
-
import 'package:flutter_web_auth_2/flutter_web_auth_2.dart';
-
-
import '../client/oauth_client.dart';
-
import '../session/oauth_session.dart';
-
import 'flutter_runtime.dart';
-
import 'flutter_stores.dart';
-
-
/// Flutter-specific OAuth client with sensible defaults.
-
///
-
/// This is a high-level wrapper around [OAuthClient] that provides:
-
/// - Automatic storage configuration (flutter_secure_storage)
-
/// - Platform-specific crypto (pointycastle + crypto package)
-
/// - In-memory caching with TTL
-
/// - Convenient sign-in flow (authorize + FlutterWebAuth2 + callback)
-
/// - Session management (restore, revoke)
-
///
-
/// Example usage:
-
/// ```dart
-
/// // Initialize client
-
/// final client = FlutterOAuthClient(
-
/// clientMetadata: ClientMetadata(
-
/// clientId: 'https://example.com/client-metadata.json',
-
/// redirectUris: ['myapp://oauth/callback'],
-
/// scope: 'atproto transition:generic',
-
/// ),
-
/// );
-
///
-
/// // Sign in with handle
-
/// try {
-
/// final session = await client.signIn('alice.bsky.social');
-
/// print('Signed in as: ${session.sub}');
-
///
-
/// // Use the session for authenticated requests
-
/// final agent = session.pdsClient;
-
/// // ... make API calls
-
/// } catch (e) {
-
/// print('Sign in failed: $e');
-
/// }
-
///
-
/// // Later: restore session
-
/// try {
-
/// final session = await client.restore('did:plc:abc123');
-
/// print('Session restored');
-
/// } catch (e) {
-
/// print('Session restoration failed: $e');
-
/// }
-
///
-
/// // Sign out
-
/// await client.revoke('did:plc:abc123');
-
/// ```
-
class FlutterOAuthClient extends OAuthClient {
-
/// Creates a FlutterOAuthClient with Flutter-specific defaults.
-
///
-
/// Parameters:
-
/// - [clientMetadata]: Client configuration (required)
-
/// - [responseMode]: OAuth response mode (default: query)
-
/// - [allowHttp]: Allow HTTP for testing (default: false)
-
/// - [secureStorage]: Custom secure storage instance (optional)
-
/// - [dio]: Custom HTTP client (optional)
-
/// - [plcDirectoryUrl]: Custom PLC directory URL (optional)
-
/// - [handleResolverUrl]: Custom handle resolver URL (optional)
-
///
-
/// Throws [FormatException] if client metadata is invalid.
-
FlutterOAuthClient({
-
required ClientMetadata clientMetadata,
-
OAuthResponseMode responseMode = OAuthResponseMode.query,
-
bool allowHttp = false,
-
FlutterSecureStorage? secureStorage,
-
Dio? dio,
-
String? plcDirectoryUrl,
-
String? handleResolverUrl,
-
}) : super(
-
OAuthClientOptions(
-
// Config
-
responseMode: responseMode,
-
clientMetadata: clientMetadata.toJson(),
-
keyset: null, // Mobile apps are public clients
-
allowHttp: allowHttp,
-
-
// Storage (Flutter-specific)
-
stateStore: FlutterStateStore(),
-
sessionStore: FlutterSessionStore(secureStorage),
-
-
// Caches (in-memory with TTL)
-
authorizationServerMetadataCache:
-
InMemoryAuthorizationServerMetadataCache(),
-
protectedResourceMetadataCache:
-
InMemoryProtectedResourceMetadataCache(),
-
dpopNonceCache: InMemoryDpopNonceCache(),
-
didCache: FlutterDidCache(),
-
handleCache: FlutterHandleCache(),
-
-
// Platform implementation
-
runtimeImplementation: const FlutterRuntime(),
-
-
// HTTP client
-
dio: dio,
-
-
// Optional overrides
-
plcDirectoryUrl: plcDirectoryUrl,
-
handleResolverUrl: handleResolverUrl,
-
),
-
);
-
-
/// Sign in with an atProto handle, DID, or URL.
-
///
-
/// This is a convenience method that:
-
/// 1. Initiates authorization flow ([authorize])
-
/// 2. Opens browser with FlutterWebAuth2
-
/// 3. Handles OAuth callback
-
/// 4. Returns authenticated session
-
///
-
/// The [input] can be:
-
/// - An atProto handle: "alice.bsky.social"
-
/// - A DID: "did:plc:..."
-
/// - A PDS URL: "https://pds.example.com"
-
/// - An authorization server URL: "https://auth.example.com"
-
///
-
/// The [options] can specify:
-
/// - redirectUri: Override default redirect URI
-
/// - state: Application state to preserve
-
/// - scope: Override default scope
-
/// - Other OIDC parameters (prompt, display, etc.)
-
///
-
/// Returns an [OAuthSession] with authenticated access.
-
///
-
/// Throws:
-
/// - [FormatException] if parameters are invalid
-
/// - [OAuthResolverError] if identity resolution fails
-
/// - [OAuthCallbackError] if authentication fails
-
/// - [Exception] if user cancels (flutter_web_auth_2 throws PlatformException)
-
///
-
/// Example:
-
/// ```dart
-
/// // Simple sign in
-
/// final session = await client.signIn('alice.bsky.social');
-
///
-
/// // With custom state
-
/// final session = await client.signIn(
-
/// 'alice.bsky.social',
-
/// options: AuthorizeOptions(state: 'my-app-state'),
-
/// );
-
/// ```
-
Future<OAuthSession> signIn(
-
String input, {
-
AuthorizeOptions? options,
-
CancelToken? cancelToken,
-
}) async {
-
// CRITICAL: Use HTTPS redirect URI for OAuth (prevents browser retry)
-
// but listen for CUSTOM SCHEME in FlutterWebAuth2 (only custom schemes can be intercepted)
-
// The HTTPS page will redirect to custom scheme, triggering the callback
-
final redirectUri =
-
options?.redirectUri ?? clientMetadata.redirectUris.first;
-
-
if (!clientMetadata.redirectUris.contains(redirectUri)) {
-
throw FormatException('Invalid redirect_uri: $redirectUri');
-
}
-
-
// Find the custom scheme redirect URI from the list
-
// FlutterWebAuth2 can ONLY intercept custom schemes, not HTTPS
-
final customSchemeUri = clientMetadata.redirectUris.firstWhere(
-
(uri) => !uri.startsWith('http://') && !uri.startsWith('https://'),
-
orElse:
-
() => redirectUri, // Fallback to primary if no custom scheme found
-
);
-
-
final callbackUrlScheme = _extractScheme(customSchemeUri);
-
-
// Step 1: Start OAuth authorization flow
-
final authUrl = await authorize(
-
input,
-
options:
-
options != null
-
? AuthorizeOptions(
-
redirectUri: redirectUri,
-
state: options.state,
-
scope: options.scope,
-
nonce: options.nonce,
-
dpopJkt: options.dpopJkt,
-
maxAge: options.maxAge,
-
claims: options.claims,
-
uiLocales: options.uiLocales,
-
idTokenHint: options.idTokenHint,
-
display: options.display ?? 'touch', // Mobile-friendly default
-
prompt: options.prompt,
-
authorizationDetails: options.authorizationDetails,
-
)
-
: AuthorizeOptions(
-
redirectUri: redirectUri,
-
display: 'touch', // Mobile-friendly default
-
),
-
cancelToken: cancelToken,
-
);
-
-
// Step 2: Open browser for user authentication
-
if (kDebugMode) {
-
print('๐Ÿ” Opening browser for OAuth...');
-
print(' Auth URL: $authUrl');
-
print(' OAuth redirect URI (PDS will redirect here): $redirectUri');
-
print(
-
' FlutterWebAuth2 callback scheme (listening for): $callbackUrlScheme',
-
);
-
}
-
-
String? callbackUrl;
-
try {
-
if (kDebugMode) {
-
print('๐Ÿ“ฑ Calling FlutterWebAuth2.authenticate()...');
-
}
-
-
callbackUrl = await FlutterWebAuth2.authenticate(
-
url: authUrl.toString(),
-
callbackUrlScheme: callbackUrlScheme,
-
options: const FlutterWebAuth2Options(
-
// Use ephemeral session to force browser to close immediately
-
// This prevents browser retry that can invalidate the authorization code
-
preferEphemeral: true,
-
timeout: 300, // 5 minutes timeout
-
),
-
);
-
-
if (kDebugMode) {
-
print('โœ… FlutterWebAuth2 returned successfully!');
-
print(' Callback URL: $callbackUrl');
-
print(
-
' โฑ๏ธ Callback received at: ${DateTime.now().toIso8601String()}',
-
);
-
}
-
} catch (e, stackTrace) {
-
if (kDebugMode) {
-
print('โŒ FlutterWebAuth2.authenticate() threw an error:');
-
print(' Error type: ${e.runtimeType}');
-
print(' Error message: $e');
-
print(' Stack trace: $stackTrace');
-
}
-
rethrow;
-
}
-
-
// Step 3: Parse callback URL parameters
-
final uri = Uri.parse(callbackUrl);
-
final params =
-
responseMode == OAuthResponseMode.fragment
-
? _parseFragment(uri.fragment)
-
: Map<String, String>.from(uri.queryParameters);
-
-
if (kDebugMode) {
-
print('๐Ÿ”„ Parsing callback parameters...');
-
print(' Response mode: $responseMode');
-
print(' Callback params: $params');
-
}
-
-
// Step 4: Complete OAuth flow
-
if (kDebugMode) {
-
print('๐Ÿ“ž Calling callback() to exchange code for tokens...');
-
print(' Redirect URI: $redirectUri');
-
}
-
-
final result = await callback(
-
params,
-
options: CallbackOptions(redirectUri: redirectUri),
-
cancelToken: cancelToken,
-
);
-
-
if (kDebugMode) {
-
print('โœ… Token exchange successful!');
-
print(' Session DID: ${result.session.sub}');
-
}
-
-
return result.session;
-
}
-
-
/// Extracts the URL scheme from a redirect URI.
-
///
-
/// Examples:
-
/// - "myapp://oauth/callback" โ†’ "myapp"
-
/// - "https://example.com/callback" โ†’ "https"
-
String _extractScheme(String redirectUri) {
-
final uri = Uri.parse(redirectUri);
-
return uri.scheme;
-
}
-
-
/// Parses URL fragment into a parameter map.
-
///
-
/// The fragment may start with '#' which we strip.
-
Map<String, String> _parseFragment(String fragment) {
-
// Remove leading '#' if present
-
final clean = fragment.startsWith('#') ? fragment.substring(1) : fragment;
-
if (clean.isEmpty) return {};
-
-
final params = <String, String>{};
-
for (final pair in clean.split('&')) {
-
final parts = pair.split('=');
-
if (parts.length == 2) {
-
params[Uri.decodeComponent(parts[0])] = Uri.decodeComponent(parts[1]);
-
}
-
}
-
return params;
-
}
-
}
-141
packages/atproto_oauth_flutter/lib/src/platform/flutter_oauth_router_helper.dart
···
-
/// Helper for configuring Flutter routers to work with OAuth callbacks.
-
///
-
/// When using declarative routing packages (go_router, auto_route, etc.),
-
/// OAuth callback deep links may be intercepted before flutter_web_auth_2
-
/// can handle them. This helper provides utilities to configure your router
-
/// to ignore OAuth callback URIs.
-
///
-
/// ## go_router Example
-
///
-
/// ```dart
-
/// final router = GoRouter(
-
/// routes: [...],
-
/// redirect: FlutterOAuthRouterHelper.createGoRouterRedirect(
-
/// customSchemes: ['com.example.myapp'],
-
/// ),
-
/// );
-
/// ```
-
///
-
/// ## Manual Configuration
-
///
-
/// ```dart
-
/// final router = GoRouter(
-
/// routes: [...],
-
/// redirect: (context, state) {
-
/// if (FlutterOAuthRouterHelper.isOAuthCallback(
-
/// state.uri,
-
/// customSchemes: ['com.example.myapp'],
-
/// )) {
-
/// return null; // Let flutter_web_auth_2 handle it
-
/// }
-
/// return null; // Normal routing
-
/// },
-
/// );
-
/// ```
-
library;
-
-
import 'dart:async';
-
import 'package:flutter/foundation.dart';
-
import 'package:flutter/widgets.dart';
-
-
/// Helper class for configuring routers to work with OAuth callbacks.
-
class FlutterOAuthRouterHelper {
-
/// Checks if a URI is an OAuth callback that should be ignored by the router.
-
///
-
/// Returns `true` if the URI uses a custom scheme from [customSchemes],
-
/// indicating it's an OAuth callback deep link that flutter_web_auth_2
-
/// should handle.
-
///
-
/// Example:
-
/// ```dart
-
/// if (FlutterOAuthRouterHelper.isOAuthCallback(
-
/// uri,
-
/// customSchemes: ['com.example.myapp'],
-
/// )) {
-
/// // This is an OAuth callback - don't route it
-
/// return null;
-
/// }
-
/// ```
-
static bool isOAuthCallback(Uri uri, {required List<String> customSchemes}) {
-
return customSchemes.contains(uri.scheme);
-
}
-
-
/// Creates a redirect function for go_router that ignores OAuth callbacks.
-
///
-
/// This is a convenience method that returns a redirect function you can
-
/// pass directly to GoRouter's `redirect` parameter.
-
///
-
/// Parameters:
-
/// - [customSchemes]: List of custom URL schemes used for OAuth callbacks
-
/// (e.g., `['com.example.myapp']`)
-
/// - [fallbackRedirect]: Optional custom redirect logic for non-OAuth URIs
-
///
-
/// Example:
-
/// ```dart
-
/// final router = GoRouter(
-
/// routes: [...],
-
/// redirect: FlutterOAuthRouterHelper.createGoRouterRedirect(
-
/// customSchemes: ['com.example.myapp'],
-
/// ),
-
/// );
-
/// ```
-
///
-
/// With custom redirect logic:
-
/// ```dart
-
/// final router = GoRouter(
-
/// routes: [...],
-
/// redirect: FlutterOAuthRouterHelper.createGoRouterRedirect(
-
/// customSchemes: ['com.example.myapp'],
-
/// fallbackRedirect: (context, state) {
-
/// // Your custom auth redirect logic
-
/// if (!isAuthenticated) return '/login';
-
/// return null;
-
/// },
-
/// ),
-
/// );
-
/// ```
-
static FutureOr<String?> Function(BuildContext, dynamic)
-
createGoRouterRedirect({
-
required List<String> customSchemes,
-
FutureOr<String?> Function(BuildContext, dynamic)? fallbackRedirect,
-
}) {
-
return (BuildContext context, dynamic state) {
-
// Extract URI from the state object (works with any router's state object that has a 'uri' property)
-
final uri = (state as dynamic).uri as Uri;
-
-
// Check if this is an OAuth callback
-
if (isOAuthCallback(uri, customSchemes: customSchemes)) {
-
// Let flutter_web_auth_2 handle OAuth callbacks
-
if (kDebugMode) {
-
print('๐Ÿ”€ RouterHelper: Detected OAuth callback - allowing through');
-
print(' URI: $uri');
-
}
-
return null;
-
}
-
-
// Apply custom redirect logic if provided
-
if (fallbackRedirect != null) {
-
return fallbackRedirect(context, state);
-
}
-
-
// No redirect needed
-
return null;
-
};
-
}
-
-
/// Extracts the scheme from a redirect URI.
-
///
-
/// This is useful for getting the custom scheme from your OAuth configuration.
-
///
-
/// Example:
-
/// ```dart
-
/// final scheme = FlutterOAuthRouterHelper.extractScheme(
-
/// 'com.example.myapp:/oauth/callback'
-
/// );
-
/// // Returns: 'com.example.myapp'
-
/// ```
-
static String extractScheme(String redirectUri) {
-
final uri = Uri.parse(redirectUri);
-
return uri.scheme;
-
}
-
}
-91
packages/atproto_oauth_flutter/lib/src/platform/flutter_runtime.dart
···
-
import 'dart:math';
-
import 'dart:typed_data';
-
-
import 'package:crypto/crypto.dart' as crypto;
-
-
import '../runtime/runtime_implementation.dart';
-
import '../utils/lock.dart';
-
import 'flutter_key.dart';
-
-
/// Flutter implementation of RuntimeImplementation.
-
///
-
/// Provides cryptographic operations for OAuth flows using:
-
/// - pointycastle for EC key generation (via FlutterKey)
-
/// - crypto package for SHA hashing
-
/// - Random.secure() for cryptographically secure random values
-
/// - requestLocalLock for concurrency control
-
///
-
/// This implementation supports:
-
/// - ES256, ES384, ES512, ES256K (Elliptic Curve algorithms)
-
/// - SHA-256, SHA-384, SHA-512 (Hash algorithms)
-
/// - Secure random number generation
-
/// - Local (in-memory) locking for token refresh
-
///
-
/// Example:
-
/// ```dart
-
/// final runtime = FlutterRuntime();
-
///
-
/// // Generate a key
-
/// final key = await runtime.createKey(['ES256', 'ES384']);
-
///
-
/// // Hash some data
-
/// final hash = await runtime.digest(
-
/// Uint8List.fromList([1, 2, 3]),
-
/// DigestAlgorithm.sha256(),
-
/// );
-
///
-
/// // Generate random bytes
-
/// final random = await runtime.getRandomValues(32);
-
/// ```
-
class FlutterRuntime implements RuntimeImplementation {
-
/// Creates a FlutterRuntime instance.
-
const FlutterRuntime();
-
-
@override
-
RuntimeKeyFactory get createKey {
-
return (List<String> algs) async {
-
return FlutterKey.generate(algs);
-
};
-
}
-
-
@override
-
RuntimeDigest get digest {
-
return (Uint8List bytes, DigestAlgorithm algorithm) async {
-
switch (algorithm.name) {
-
case 'sha256':
-
case 'SHA-256':
-
return Uint8List.fromList(crypto.sha256.convert(bytes).bytes);
-
-
case 'sha384':
-
case 'SHA-384':
-
return Uint8List.fromList(crypto.sha384.convert(bytes).bytes);
-
-
case 'sha512':
-
case 'SHA-512':
-
return Uint8List.fromList(crypto.sha512.convert(bytes).bytes);
-
-
default:
-
throw UnsupportedError(
-
'Unsupported digest algorithm: ${algorithm.name}',
-
);
-
}
-
};
-
}
-
-
@override
-
RuntimeRandomValues get getRandomValues {
-
return (int length) async {
-
final random = Random.secure();
-
return Uint8List.fromList(
-
List.generate(length, (_) => random.nextInt(256)),
-
);
-
};
-
}
-
-
@override
-
RuntimeLock get requestLock {
-
// Use the local lock implementation from utils/lock.dart
-
// This prevents concurrent token refresh within a single isolate
-
return requestLocalLock;
-
}
-
}
-341
packages/atproto_oauth_flutter/lib/src/platform/flutter_stores.dart
···
-
import 'dart:convert';
-
-
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
-
-
import '../identity/did_document.dart';
-
import '../identity/did_resolver.dart';
-
import '../identity/handle_resolver.dart';
-
import '../oauth/authorization_server_metadata_resolver.dart';
-
import '../oauth/oauth_server_agent.dart';
-
import '../oauth/protected_resource_metadata_resolver.dart';
-
import '../session/oauth_session.dart';
-
import '../session/session_getter.dart';
-
import '../session/state_store.dart';
-
import '../util.dart';
-
-
// ============================================================================
-
// Session and State Storage (uses flutter_secure_storage)
-
// ============================================================================
-
-
/// Flutter implementation of SessionStore using flutter_secure_storage.
-
///
-
/// This stores OAuth sessions (tokens and keys) in the device's secure storage:
-
/// - iOS: Keychain
-
/// - Android: EncryptedSharedPreferences
-
///
-
/// Sessions are persisted across app restarts and are encrypted at rest.
-
///
-
/// Example:
-
/// ```dart
-
/// final store = FlutterSessionStore();
-
/// await store.set('did:plc:abc123', session);
-
/// final restored = await store.get('did:plc:abc123');
-
/// ```
-
class FlutterSessionStore implements SessionStore {
-
final FlutterSecureStorage _storage;
-
static const _prefix = 'atproto_session_';
-
-
FlutterSessionStore([FlutterSecureStorage? storage])
-
: _storage =
-
storage ??
-
const FlutterSecureStorage(
-
aOptions: AndroidOptions(encryptedSharedPreferences: true),
-
);
-
-
@override
-
Future<Session?> get(String key, {CancellationToken? signal}) async {
-
try {
-
final json = await _storage.read(key: _prefix + key);
-
if (json == null) return null;
-
-
final data = jsonDecode(json) as Map<String, dynamic>;
-
return Session.fromJson(data);
-
} catch (e) {
-
return null;
-
}
-
}
-
-
@override
-
Future<void> set(String key, Session value) async {
-
final json = jsonEncode(value.toJson());
-
await _storage.write(key: _prefix + key, value: json);
-
}
-
-
@override
-
Future<void> del(String key) async {
-
await _storage.delete(key: _prefix + key);
-
}
-
-
@override
-
Future<void> clear() async {
-
// Delete all session keys
-
final all = await _storage.readAll();
-
for (final key in all.keys) {
-
if (key.startsWith(_prefix)) {
-
await _storage.delete(key: key);
-
}
-
}
-
}
-
}
-
-
/// Flutter implementation of StateStore for ephemeral OAuth state.
-
///
-
/// This stores temporary state data during the OAuth authorization flow.
-
/// State data includes PKCE verifiers, nonces, and application state.
-
///
-
/// Uses in-memory storage since state is short-lived (only needed during the
-
/// authorization flow, which typically completes within minutes).
-
///
-
/// Example:
-
/// ```dart
-
/// final store = FlutterStateStore();
-
/// await store.set('state123', InternalStateData(...));
-
/// final state = await store.get('state123');
-
/// await store.del('state123'); // Clean up after use
-
/// ```
-
class FlutterStateStore implements StateStore {
-
final Map<String, InternalStateData> _store = {};
-
-
@override
-
Future<InternalStateData?> get(String key) async {
-
return _store[key];
-
}
-
-
@override
-
Future<void> set(String key, InternalStateData data) async {
-
_store[key] = data;
-
}
-
-
@override
-
Future<void> del(String key) async {
-
_store.remove(key);
-
}
-
-
@override
-
Future<void> clear() async {
-
_store.clear();
-
}
-
}
-
-
// ============================================================================
-
// In-Memory Caches with TTL
-
// ============================================================================
-
-
/// Base class for in-memory caches with time-to-live (TTL).
-
///
-
/// This provides a generic caching mechanism with automatic expiration.
-
/// Cached items are stored with a timestamp and are considered stale
-
/// after the TTL period.
-
class _InMemoryCache<V> {
-
final Map<String, _CacheEntry<V>> _cache = {};
-
final Duration _ttl;
-
-
_InMemoryCache(this._ttl);
-
-
Future<V?> get(String key) async {
-
final entry = _cache[key];
-
if (entry == null) return null;
-
-
// Check if expired
-
if (DateTime.now().isAfter(entry.expiresAt)) {
-
_cache.remove(key);
-
return null;
-
}
-
-
return entry.value;
-
}
-
-
Future<void> set(String key, V value) async {
-
_cache[key] = _CacheEntry(
-
value: value,
-
expiresAt: DateTime.now().add(_ttl),
-
);
-
}
-
-
Future<void> del(String key) async {
-
_cache.remove(key);
-
}
-
-
Future<void> clear() async {
-
_cache.clear();
-
}
-
-
/// Removes expired entries from the cache.
-
void purge() {
-
final now = DateTime.now();
-
_cache.removeWhere((_, entry) => now.isAfter(entry.expiresAt));
-
}
-
}
-
-
/// Cache entry with expiration time.
-
class _CacheEntry<V> {
-
final V value;
-
final DateTime expiresAt;
-
-
_CacheEntry({required this.value, required this.expiresAt});
-
}
-
-
/// In-memory cache for OAuth Authorization Server metadata.
-
///
-
/// Caches metadata fetched from /.well-known/oauth-authorization-server
-
/// to avoid redundant network requests.
-
///
-
/// Default TTL: 1 minute (metadata rarely changes)
-
///
-
/// Example:
-
/// ```dart
-
/// final cache = InMemoryAuthorizationServerMetadataCache();
-
/// await cache.set('https://auth.example.com', metadata);
-
/// final cached = await cache.get('https://auth.example.com');
-
/// ```
-
class InMemoryAuthorizationServerMetadataCache
-
implements AuthorizationServerMetadataCache {
-
final _InMemoryCache<Map<String, dynamic>> _cache;
-
-
InMemoryAuthorizationServerMetadataCache({
-
Duration ttl = const Duration(minutes: 1),
-
}) : _cache = _InMemoryCache(ttl);
-
-
@override
-
Future<Map<String, dynamic>?> get(String key, {CancellationToken? signal}) =>
-
_cache.get(key);
-
-
@override
-
Future<void> set(String key, Map<String, dynamic> value) =>
-
_cache.set(key, value);
-
-
@override
-
Future<void> del(String key) => _cache.del(key);
-
-
@override
-
Future<void> clear() => _cache.clear();
-
}
-
-
/// In-memory cache for OAuth Protected Resource metadata.
-
///
-
/// Caches metadata fetched from /.well-known/oauth-protected-resource
-
/// to avoid redundant network requests.
-
///
-
/// Default TTL: 1 minute (metadata rarely changes)
-
///
-
/// Example:
-
/// ```dart
-
/// final cache = InMemoryProtectedResourceMetadataCache();
-
/// await cache.set('https://pds.example.com', metadata);
-
/// ```
-
class InMemoryProtectedResourceMetadataCache
-
implements ProtectedResourceMetadataCache {
-
final _InMemoryCache<Map<String, dynamic>> _cache;
-
-
InMemoryProtectedResourceMetadataCache({
-
Duration ttl = const Duration(minutes: 1),
-
}) : _cache = _InMemoryCache(ttl);
-
-
@override
-
Future<Map<String, dynamic>?> get(String key, {CancellationToken? signal}) =>
-
_cache.get(key);
-
-
@override
-
Future<void> set(String key, Map<String, dynamic> value) =>
-
_cache.set(key, value);
-
-
@override
-
Future<void> del(String key) => _cache.del(key);
-
-
@override
-
Future<void> clear() => _cache.clear();
-
}
-
-
/// In-memory cache for DPoP nonces.
-
///
-
/// DPoP nonces are server-provided values used for replay protection.
-
/// They're cached per authorization/resource server origin.
-
///
-
/// Default TTL: 10 minutes (nonces typically have short lifetimes)
-
///
-
/// Example:
-
/// ```dart
-
/// final cache = InMemoryDpopNonceCache();
-
/// await cache.set('https://auth.example.com', 'nonce123');
-
/// final nonce = await cache.get('https://auth.example.com');
-
/// ```
-
class InMemoryDpopNonceCache implements DpopNonceCache {
-
final _InMemoryCache<String> _cache;
-
-
InMemoryDpopNonceCache({Duration ttl = const Duration(minutes: 10)})
-
: _cache = _InMemoryCache(ttl);
-
-
@override
-
Future<String?> get(String key, {CancellationToken? signal}) =>
-
_cache.get(key);
-
-
@override
-
Future<void> set(String key, String value) => _cache.set(key, value);
-
-
@override
-
Future<void> del(String key) => _cache.del(key);
-
-
@override
-
Future<void> clear() => _cache.clear();
-
}
-
-
/// In-memory cache for DID documents.
-
///
-
/// Caches resolved DID documents (from DidDocument class) to avoid redundant
-
/// resolution requests.
-
///
-
/// Default TTL: 1 minute (DID documents can change but not frequently)
-
///
-
/// Note: DidDocument is a complex class, but it has toJson/fromJson methods.
-
/// We store the JSON representation and reconstruct on retrieval.
-
///
-
/// Example:
-
/// ```dart
-
/// final cache = FlutterDidCache();
-
/// await cache.set('did:plc:abc123', didDocument);
-
/// final doc = await cache.get('did:plc:abc123');
-
/// ```
-
class FlutterDidCache implements DidCache {
-
final _InMemoryCache<DidDocument> _cache;
-
-
FlutterDidCache({Duration ttl = const Duration(minutes: 1)})
-
: _cache = _InMemoryCache(ttl);
-
-
@override
-
Future<DidDocument?> get(String key) => _cache.get(key);
-
-
@override
-
Future<void> set(String key, DidDocument value) => _cache.set(key, value);
-
-
@override
-
Future<void> clear() => _cache.clear();
-
}
-
-
/// In-memory cache for handle โ†’ DID resolutions.
-
///
-
/// Caches the resolution of atProto handles (e.g., "alice.bsky.social") to DIDs.
-
/// The cache stores simple string mappings (handle โ†’ DID).
-
///
-
/// Default TTL: 1 minute (handles can be reassigned but not frequently)
-
///
-
/// Example:
-
/// ```dart
-
/// final cache = FlutterHandleCache();
-
/// await cache.set('alice.bsky.social', 'did:plc:abc123');
-
/// final did = await cache.get('alice.bsky.social');
-
/// ```
-
class FlutterHandleCache implements HandleCache {
-
final _InMemoryCache<String> _cache;
-
-
FlutterHandleCache({Duration ttl = const Duration(minutes: 1)})
-
: _cache = _InMemoryCache(ttl);
-
-
@override
-
Future<String?> get(String key) => _cache.get(key);
-
-
@override
-
Future<void> set(String key, String value) => _cache.set(key, value);
-
-
@override
-
Future<void> clear() => _cache.clear();
-
}
-280
packages/atproto_oauth_flutter/lib/src/runtime/runtime.dart
···
-
import 'dart:convert';
-
import 'dart:typed_data';
-
-
import '../utils/lock.dart';
-
import 'runtime_implementation.dart';
-
-
/// Main runtime class that wraps a RuntimeImplementation and provides
-
/// high-level cryptographic operations for OAuth.
-
///
-
/// This class handles:
-
/// - Key generation with algorithm preference sorting
-
/// - SHA-256 hashing with base64url encoding
-
/// - Nonce generation
-
/// - PKCE (Proof Key for Code Exchange) generation
-
/// - JWK thumbprint calculation
-
///
-
/// All operations use the underlying RuntimeImplementation for
-
/// platform-specific cryptographic primitives.
-
class Runtime {
-
final RuntimeImplementation _implementation;
-
-
/// Whether the implementation provides a custom lock mechanism.
-
final bool hasImplementationLock;
-
-
/// The lock function to use (either custom or local fallback).
-
final RuntimeLock usingLock;
-
-
Runtime(this._implementation)
-
: hasImplementationLock = _implementation.requestLock != null,
-
usingLock = _implementation.requestLock ?? requestLocalLock;
-
-
/// Generates a cryptographic key that supports the given algorithms.
-
///
-
/// The algorithms are sorted by preference before being passed to the
-
/// key factory. This ensures consistent key selection across platforms.
-
///
-
/// Algorithm preference order (most to least preferred):
-
/// 1. ES256K (secp256k1)
-
/// 2. ES256, ES384, ES512 (elliptic curve, shorter keys first)
-
/// 3. PS256, PS384, PS512 (RSA-PSS, shorter keys first)
-
/// 4. RS256, RS384, RS512 (RSA-PKCS1, shorter keys first)
-
/// 5. Other algorithms (maintain original order)
-
///
-
/// Example:
-
/// ```dart
-
/// final key = await runtime.generateKey(['ES256', 'RS256', 'ES384']);
-
/// // Returns key supporting ES256 (preferred over RS256 and ES384)
-
/// ```
-
Future<Key> generateKey(List<String> algs) async {
-
final algsSorted = List<String>.from(algs)..sort(_compareAlgos);
-
return _implementation.createKey(algsSorted);
-
}
-
-
/// Computes the SHA-256 hash of the input text and returns it as base64url.
-
///
-
/// This is used extensively in OAuth for:
-
/// - PKCE code challenge (S256 method)
-
/// - JWK thumbprint calculation
-
/// - DPoP access token hash (ath claim)
-
///
-
/// Example:
-
/// ```dart
-
/// final hash = await runtime.sha256('hello world');
-
/// // Returns base64url-encoded SHA-256 hash
-
/// ```
-
Future<String> sha256(String text) async {
-
final bytes = utf8.encode(text);
-
final digest = await _implementation.digest(
-
Uint8List.fromList(bytes),
-
const DigestAlgorithm.sha256(),
-
);
-
return _base64UrlEncode(digest);
-
}
-
-
/// Generates a cryptographically secure random nonce.
-
///
-
/// The nonce is base64url-encoded and has the specified byte length
-
/// (default 16 bytes = 128 bits of entropy).
-
///
-
/// Used for:
-
/// - OAuth state parameter
-
/// - OIDC nonce parameter
-
/// - DPoP jti (JWT ID) claim
-
///
-
/// Example:
-
/// ```dart
-
/// final nonce = await runtime.generateNonce(); // 16 bytes
-
/// final longNonce = await runtime.generateNonce(32); // 32 bytes
-
/// ```
-
Future<String> generateNonce([int length = 16]) async {
-
final bytes = await _implementation.getRandomValues(length);
-
return _base64UrlEncode(bytes);
-
}
-
-
/// Generates PKCE (Proof Key for Code Exchange) parameters.
-
///
-
/// PKCE is a security extension for OAuth that prevents authorization code
-
/// interception attacks. It's required for public clients (mobile/desktop apps).
-
///
-
/// Returns a map with:
-
/// - `verifier`: Random code verifier (base64url-encoded)
-
/// - `challenge`: SHA-256 hash of verifier (base64url-encoded)
-
/// - `method`: 'S256' (indicating SHA-256 hashing method)
-
///
-
/// The verifier should be stored securely and sent during token exchange.
-
/// The challenge is sent during authorization.
-
///
-
/// See: https://datatracker.ietf.org/doc/html/rfc7636
-
///
-
/// Example:
-
/// ```dart
-
/// final pkce = await runtime.generatePKCE();
-
/// // Use pkce['challenge'] in authorization request
-
/// // Store pkce['verifier'] for token exchange
-
/// ```
-
Future<Map<String, String>> generatePKCE([int? byteLength]) async {
-
final verifier = await _generateVerifier(byteLength);
-
final challenge = await sha256(verifier);
-
return {'verifier': verifier, 'challenge': challenge, 'method': 'S256'};
-
}
-
-
/// Calculates the JWK thumbprint (jkt) for a given JSON Web Key.
-
///
-
/// The thumbprint is a hash of the key's essential components, used to
-
/// uniquely identify a key. For DPoP, this binds tokens to specific keys.
-
///
-
/// The calculation follows RFC 7638:
-
/// 1. Extract required components based on key type (kty)
-
/// 2. Create canonical JSON representation
-
/// 3. Compute SHA-256 hash
-
/// 4. Base64url-encode the result
-
///
-
/// Required components by key type:
-
/// - EC: crv, kty, x, y
-
/// - OKP: crv, kty, x
-
/// - RSA: e, kty, n
-
/// - oct: k, kty
-
///
-
/// See: https://datatracker.ietf.org/doc/html/rfc7638
-
///
-
/// Example:
-
/// ```dart
-
/// final thumbprint = await runtime.calculateJwkThumbprint(jwk);
-
/// // Returns base64url-encoded SHA-256 hash of key components
-
/// ```
-
Future<String> calculateJwkThumbprint(Map<String, dynamic> jwk) async {
-
final components = _extractJktComponents(jwk);
-
final data = jsonEncode(components);
-
return sha256(data);
-
}
-
-
/// Generates a PKCE code verifier.
-
///
-
/// The verifier is a cryptographically random string that:
-
/// - Has length between 43-128 characters (32-96 bytes before encoding)
-
/// - Is base64url-encoded
-
/// - SHOULD be 32 bytes (43 chars) per RFC 7636 recommendations
-
///
-
/// See: https://datatracker.ietf.org/doc/html/rfc7636#section-4.1
-
Future<String> _generateVerifier([int? byteLength]) async {
-
final length = byteLength ?? 32;
-
-
if (length < 32 || length > 96) {
-
throw ArgumentError(
-
'Invalid code_verifier length: must be between 32 and 96 bytes',
-
);
-
}
-
-
final bytes = await _implementation.getRandomValues(length);
-
return _base64UrlEncode(bytes);
-
}
-
-
/// Base64url encodes a byte array without padding.
-
///
-
/// Base64url encoding is standard base64 with URL-safe characters:
-
/// - '+' becomes '-'
-
/// - '/' becomes '_'
-
/// - Padding ('=') is removed
-
///
-
/// This is the encoding used throughout OAuth and JWT specifications.
-
String _base64UrlEncode(Uint8List bytes) {
-
return base64Url.encode(bytes).replaceAll('=', '');
-
}
-
}
-
-
/// Extracts the required components from a JWK for thumbprint calculation.
-
///
-
/// This follows RFC 7638 which specifies exactly which fields to include
-
/// in the thumbprint hash for each key type.
-
///
-
/// The components are returned in a Map that will be serialized to JSON
-
/// in lexicographic order (Dart's jsonEncode naturally does this).
-
///
-
/// Throws ArgumentError if:
-
/// - Required fields are missing
-
/// - Key type (kty) is unsupported
-
Map<String, String> _extractJktComponents(Map<String, dynamic> jwk) {
-
String getRequired(String field) {
-
final value = jwk[field];
-
if (value is! String || value.isEmpty) {
-
throw ArgumentError('"$field" parameter missing or invalid');
-
}
-
return value;
-
}
-
-
final kty = getRequired('kty');
-
-
switch (kty) {
-
case 'EC':
-
// Elliptic Curve keys (ES256, ES384, ES512, ES256K)
-
return {
-
'crv': getRequired('crv'),
-
'kty': kty,
-
'x': getRequired('x'),
-
'y': getRequired('y'),
-
};
-
-
case 'OKP':
-
// Octet Key Pair (EdDSA)
-
return {'crv': getRequired('crv'), 'kty': kty, 'x': getRequired('x')};
-
-
case 'RSA':
-
// RSA keys (RS256, RS384, RS512, PS256, PS384, PS512)
-
return {'e': getRequired('e'), 'kty': kty, 'n': getRequired('n')};
-
-
case 'oct':
-
// Symmetric keys (HS256, HS384, HS512)
-
return {'k': getRequired('k'), 'kty': kty};
-
-
default:
-
throw ArgumentError(
-
'"kty" (Key Type) parameter missing or unsupported: $kty',
-
);
-
}
-
}
-
-
/// Compares two algorithm strings for preference ordering.
-
///
-
/// Algorithm preference order:
-
/// 1. ES256K (secp256k1) - always most preferred
-
/// 2. ES* (Elliptic Curve) - prefer shorter keys
-
/// - ES256 > ES384 > ES512
-
/// 3. PS* (RSA-PSS) - prefer shorter keys
-
/// - PS256 > PS384 > PS512
-
/// 4. RS* (RSA-PKCS1) - prefer shorter keys
-
/// - RS256 > RS384 > RS512
-
/// 5. Other algorithms - maintain original order
-
///
-
/// Returns:
-
/// - Negative if `a` is preferred over `b`
-
/// - Positive if `b` is preferred over `a`
-
/// - Zero if no preference (maintain order)
-
int _compareAlgos(String a, String b) {
-
// ES256K is always most preferred
-
if (a == 'ES256K') return -1;
-
if (b == 'ES256K') return 1;
-
-
// Check algorithm families in preference order: ES > PS > RS
-
for (final prefix in ['ES', 'PS', 'RS']) {
-
if (a.startsWith(prefix)) {
-
if (b.startsWith(prefix)) {
-
// Both have same prefix, prefer shorter key length
-
// Extract the number (e.g., "256" from "ES256")
-
final aLen = int.tryParse(a.substring(2, 5)) ?? 0;
-
final bLen = int.tryParse(b.substring(2, 5)) ?? 0;
-
-
// Prefer shorter keys (256 < 384 < 512)
-
return aLen - bLen;
-
}
-
// 'a' has the prefix, 'b' doesn't - prefer 'a'
-
return -1;
-
} else if (b.startsWith(prefix)) {
-
// 'b' has the prefix, 'a' doesn't - prefer 'b'
-
return 1;
-
}
-
}
-
-
// No known preference, maintain original order
-
return 0;
-
}
-167
packages/atproto_oauth_flutter/lib/src/runtime/runtime_implementation.dart
···
-
import 'dart:async';
-
import 'dart:typed_data';
-
-
/// Represents a cryptographic key that can sign and verify JWTs.
-
///
-
/// This is a placeholder for the Key class from @atproto/jwk.
-
/// In the full implementation, this should be imported from the jwk package.
-
///
-
/// The Key class contains:
-
/// - JWK representation (public and private)
-
/// - Supported algorithms
-
/// - createJwt() method for signing
-
/// - verifyJwt() method for verification
-
///
-
/// ## Key Serialization (IMPLEMENTED)
-
///
-
/// DPoP keys are fully serialized and persisted in session storage via:
-
///
-
/// 1. FlutterKey.toJson() / FlutterKey.privateJwk:
-
/// - Serializes the full JWK including private key components
-
/// - Used when storing sessions to secure storage
-
///
-
/// 2. FlutterKey.fromJwk(Map<String, dynamic> jwk):
-
/// - Reconstructs a Key from serialized JWK
-
/// - Validates JWK structure and throws on corruption
-
/// - Used when restoring sessions from storage
-
///
-
/// This ensures DPoP keys persist across app restarts, maintaining
-
/// token binding consistency and avoiding unnecessary token refreshes.
-
abstract class Key {
-
/// Create a signed JWT with the given header and payload.
-
Future<String> createJwt(
-
Map<String, dynamic> header,
-
Map<String, dynamic> payload,
-
);
-
-
/// The list of algorithms this key supports.
-
List<String> get algorithms;
-
-
/// The bare JWK (public key components only, for DPoP proofs).
-
/// Returns null for symmetric keys.
-
Map<String, dynamic>? get bareJwk;
-
-
/// The key ID (kid) from the JWK.
-
/// Returns null if the key doesn't have a kid.
-
String? get kid;
-
-
/// The usage of this key ('sign' or 'enc').
-
String get usage;
-
-
// TODO: Uncomment these when implementing serialization:
-
// Map<String, dynamic> toJson();
-
// static Key fromJson(Map<String, dynamic> json);
-
}
-
-
/// Factory function that creates a cryptographic key for the given algorithms.
-
///
-
/// The key should support at least one of the provided algorithms.
-
/// Algorithms are typically in order of preference.
-
///
-
/// Common algorithms:
-
/// - ES256, ES384, ES512 (Elliptic Curve)
-
/// - ES256K (secp256k1)
-
/// - RS256, RS384, RS512 (RSA)
-
/// - PS256, PS384, PS512 (RSA-PSS)
-
typedef RuntimeKeyFactory = FutureOr<Key> Function(List<String> algs);
-
-
/// Generates cryptographically secure random bytes.
-
///
-
/// Returns a Uint8List of the specified length filled with random bytes.
-
/// Must use a cryptographically secure random number generator.
-
typedef RuntimeRandomValues = FutureOr<Uint8List> Function(int length);
-
-
/// Digest algorithm specification.
-
class DigestAlgorithm {
-
/// The hash algorithm name: 'sha256', 'sha384', or 'sha512'.
-
final String name;
-
-
const DigestAlgorithm({required this.name});
-
-
const DigestAlgorithm.sha256() : name = 'sha256';
-
const DigestAlgorithm.sha384() : name = 'sha384';
-
const DigestAlgorithm.sha512() : name = 'sha512';
-
}
-
-
/// Computes a cryptographic hash (digest) of the input data.
-
///
-
/// The algorithm specifies which hash function to use (SHA-256, SHA-384, SHA-512).
-
/// Returns the hash as a Uint8List.
-
typedef RuntimeDigest =
-
FutureOr<Uint8List> Function(Uint8List data, DigestAlgorithm alg);
-
-
/// Acquires a lock for the given name and executes the function while holding the lock.
-
///
-
/// This ensures that only one execution of the function can run at a time for a given lock name.
-
/// This is critical for preventing race conditions during token refresh operations.
-
///
-
/// Example:
-
/// ```dart
-
/// final result = await requestLock('token-refresh', () async {
-
/// // Critical section - only one execution at a time
-
/// return await refreshToken();
-
/// });
-
/// ```
-
typedef RuntimeLock =
-
Future<T> Function<T>(String name, FutureOr<T> Function() fn);
-
-
/// Platform-specific runtime implementation for cryptographic operations.
-
///
-
/// This interface defines the core cryptographic primitives needed for OAuth:
-
/// - Key generation (createKey)
-
/// - Random number generation (getRandomValues)
-
/// - Cryptographic hashing (digest)
-
/// - Optional locking mechanism (requestLock)
-
///
-
/// Implementations must use secure cryptographic libraries:
-
/// - For Dart: pointycastle (ECDSA), crypto (SHA hashing)
-
/// - Random values must come from dart:math.Random.secure()
-
///
-
/// Security considerations:
-
/// - Keys must be generated using cryptographically secure randomness
-
/// - Private keys must never be logged or exposed
-
/// - Hash functions must be collision-resistant (SHA-256 minimum)
-
/// - Lock implementation should prevent race conditions in token refresh
-
abstract class RuntimeImplementation {
-
/// Creates a cryptographic key that supports at least one of the given algorithms.
-
///
-
/// The algorithms list is typically sorted by preference, with the most preferred first.
-
///
-
/// For OAuth DPoP, common algorithm preferences are:
-
/// - ES256K (secp256k1) - preferred for atproto
-
/// - ES256, ES384, ES512 (NIST curves)
-
/// - PS256, PS384, PS512 (RSA-PSS)
-
/// - RS256, RS384, RS512 (RSA-PKCS1)
-
///
-
/// Throws if no suitable key can be generated for any of the algorithms.
-
RuntimeKeyFactory get createKey;
-
-
/// Generates cryptographically secure random bytes.
-
///
-
/// MUST use a cryptographically secure random number generator.
-
/// In Dart, use Random.secure() from dart:math.
-
///
-
/// Never use a regular Random() - this is a security vulnerability.
-
RuntimeRandomValues get getRandomValues;
-
-
/// Computes a cryptographic hash of the input data.
-
///
-
/// Supported algorithms: SHA-256, SHA-384, SHA-512
-
///
-
/// Implementation should use the crypto package's sha256, sha384, sha512.
-
RuntimeDigest get digest;
-
-
/// Optional platform-specific lock implementation.
-
///
-
/// If provided, this will be used to prevent concurrent token refresh operations.
-
/// If not provided, a local (in-memory) lock implementation will be used as fallback.
-
///
-
/// The lock should be:
-
/// - Re-entrant safe (same isolate can acquire multiple times)
-
/// - Fair (FIFO order)
-
/// - Automatically released on error
-
///
-
/// For Flutter apps, the default local lock is usually sufficient.
-
/// For multi-process scenarios, you may need a platform-specific implementation.
-
RuntimeLock? get requestLock;
-
}
-395
packages/atproto_oauth_flutter/lib/src/session/oauth_session.dart
···
-
import 'dart:async';
-
import 'package:dio/dio.dart';
-
import 'package:http/http.dart' as http;
-
-
import '../dpop/fetch_dpop.dart';
-
import '../errors/token_invalid_error.dart';
-
import '../errors/token_revoked_error.dart';
-
import '../oauth/oauth_server_agent.dart';
-
-
/// Type alias for AtprotoDid (user's DID)
-
typedef AtprotoDid = String;
-
-
/// Type alias for AtprotoOAuthScope
-
typedef AtprotoOAuthScope = String;
-
-
/// Placeholder for OAuthAuthorizationServerMetadata
-
/// Will be properly typed in later chunks
-
typedef OAuthAuthorizationServerMetadata = Map<String, dynamic>;
-
-
/// Information about the current token.
-
class TokenInfo {
-
/// When the token expires (null if no expiration)
-
final DateTime? expiresAt;
-
-
/// Whether the token is expired (null if no expiration)
-
final bool? expired;
-
-
/// The scope of access granted
-
final AtprotoOAuthScope scope;
-
-
/// The issuer URL
-
final String iss;
-
-
/// The audience (resource server)
-
final String aud;
-
-
/// The subject (user's DID)
-
final AtprotoDid sub;
-
-
TokenInfo({
-
this.expiresAt,
-
this.expired,
-
required this.scope,
-
required this.iss,
-
required this.aud,
-
required this.sub,
-
});
-
}
-
-
/// Abstract interface for session management.
-
///
-
/// This will be implemented by SessionGetter in session_getter.dart.
-
/// We define it here to avoid circular dependencies.
-
abstract class SessionGetterInterface {
-
Future<Session> get(AtprotoDid sub, {bool? noCache, bool? allowStale});
-
-
Future<void> delStored(AtprotoDid sub, [Object? cause]);
-
}
-
-
/// Represents an active OAuth session.
-
///
-
/// A session is created after successful authentication and provides methods
-
/// for making authenticated requests and managing the session lifecycle.
-
class Session {
-
/// The DPoP key used for this session (serialized as Map for storage)
-
final Map<String, dynamic> dpopKey;
-
-
/// The client authentication method (serialized as Map or String for storage).
-
/// Can be:
-
/// - A Map containing {method: 'private_key_jwt', kid: '...'} for private key JWT
-
/// - A Map containing {method: 'none'} for no authentication
-
/// - A String 'legacy' for backwards compatibility
-
/// - null (defaults to 'legacy' when loading)
-
final dynamic authMethod;
-
-
/// The token set containing access and refresh tokens
-
final TokenSet tokenSet;
-
-
const Session({
-
required this.dpopKey,
-
this.authMethod,
-
required this.tokenSet,
-
});
-
-
/// Creates a Session from JSON.
-
factory Session.fromJson(Map<String, dynamic> json) {
-
return Session(
-
dpopKey: json['dpopKey'] as Map<String, dynamic>,
-
authMethod: json['authMethod'], // Can be Map or String
-
tokenSet: TokenSet.fromJson(json['tokenSet'] as Map<String, dynamic>),
-
);
-
}
-
-
/// Converts this Session to JSON.
-
Map<String, dynamic> toJson() {
-
final json = <String, dynamic>{
-
'dpopKey': dpopKey,
-
'tokenSet': tokenSet.toJson(),
-
};
-
-
if (authMethod != null) json['authMethod'] = authMethod;
-
-
return json;
-
}
-
}
-
-
/// Represents an active OAuth session with methods for authenticated requests.
-
///
-
/// This class wraps an OAuth session and provides:
-
/// - Automatic token refresh on expiry
-
/// - DPoP-protected requests
-
/// - Session lifecycle management (sign out)
-
///
-
/// Example:
-
/// ```dart
-
/// final session = OAuthSession(
-
/// server: oauthServer,
-
/// sub: 'did:plc:abc123',
-
/// sessionGetter: sessionGetter,
-
/// );
-
///
-
/// // Make an authenticated request
-
/// final response = await session.fetchHandler('/api/posts');
-
///
-
/// // Get token information
-
/// final info = await session.getTokenInfo();
-
/// print('Token expires at: ${info.expiresAt}');
-
///
-
/// // Sign out
-
/// await session.signOut();
-
/// ```
-
class OAuthSession {
-
/// The OAuth server agent
-
final OAuthServerAgent server;
-
-
/// The subject (user's DID)
-
final AtprotoDid sub;
-
-
/// The session getter for retrieving and refreshing tokens
-
final SessionGetterInterface sessionGetter;
-
-
/// Dio instance with DPoP interceptor for authenticated requests
-
final Dio _dio;
-
-
/// Creates a new OAuth session.
-
///
-
/// Parameters:
-
/// - [server]: The OAuth server agent
-
/// - [sub]: The subject (user's DID)
-
/// - [sessionGetter]: The session getter for token management
-
OAuthSession({
-
required this.server,
-
required this.sub,
-
required this.sessionGetter,
-
}) : _dio = Dio() {
-
// Add DPoP interceptor for authenticated requests to resource servers
-
_dio.interceptors.add(
-
createDpopInterceptor(
-
DpopFetchWrapperOptions(
-
key: server.dpopKey,
-
nonces: server.dpopNonces,
-
sha256: server.runtime.sha256,
-
isAuthServer: false, // Resource server requests (PDS)
-
),
-
),
-
);
-
}
-
-
/// Alias for [sub]
-
AtprotoDid get did => sub;
-
-
/// The server metadata
-
OAuthAuthorizationServerMetadata get serverMetadata => server.serverMetadata;
-
-
/// Gets the current token set.
-
///
-
/// Parameters:
-
/// - [refresh]: When `true`, forces a token refresh even if not expired.
-
/// When `false`, uses cached tokens even if expired.
-
/// When `'auto'`, refreshes only if expired (default).
-
Future<TokenSet> _getTokenSet(dynamic refresh) async {
-
final session = await sessionGetter.get(
-
sub,
-
noCache: refresh == true,
-
allowStale: refresh == false,
-
);
-
-
return session.tokenSet;
-
}
-
-
/// Gets information about the current token.
-
///
-
/// Parameters:
-
/// - [refresh]: When `true`, forces a token refresh even if not expired.
-
/// When `false`, uses cached tokens even if expired.
-
/// When `'auto'`, refreshes only if expired (default).
-
Future<TokenInfo> getTokenInfo([dynamic refresh = 'auto']) async {
-
final tokenSet = await _getTokenSet(refresh);
-
final expiresAtStr = tokenSet.expiresAt;
-
final expiresAt =
-
expiresAtStr != null ? DateTime.parse(expiresAtStr) : null;
-
-
return TokenInfo(
-
expiresAt: expiresAt,
-
expired:
-
expiresAt != null
-
? expiresAt.isBefore(
-
DateTime.now().subtract(Duration(seconds: 5)),
-
)
-
: null,
-
scope: tokenSet.scope,
-
iss: tokenSet.iss,
-
aud: tokenSet.aud,
-
sub: tokenSet.sub,
-
);
-
}
-
-
/// Signs out the user.
-
///
-
/// This revokes the access token and deletes the session from storage.
-
/// Even if revocation fails, the session is removed locally.
-
Future<void> signOut() async {
-
try {
-
final tokenSet = await _getTokenSet(false);
-
await server.revoke(tokenSet.accessToken);
-
} finally {
-
await sessionGetter.delStored(sub, TokenRevokedError(sub));
-
}
-
}
-
-
/// Makes an authenticated HTTP request to the given pathname.
-
///
-
/// This method:
-
/// 1. Automatically refreshes tokens if they're expired
-
/// 2. Adds DPoP and Authorization headers
-
/// 3. Retries once with a fresh token if the initial request fails with 401
-
///
-
/// Parameters:
-
/// - [pathname]: The pathname to request (relative to the audience URL)
-
/// - [method]: HTTP method (default: 'GET')
-
/// - [headers]: Additional headers to include
-
/// - [body]: Request body
-
///
-
/// Returns the HTTP response.
-
///
-
/// Example:
-
/// ```dart
-
/// final response = await session.fetchHandler(
-
/// '/xrpc/com.atproto.repo.createRecord',
-
/// method: 'POST',
-
/// headers: {'Content-Type': 'application/json'},
-
/// body: jsonEncode({'repo': did, 'collection': 'app.bsky.feed.post', ...}),
-
/// );
-
/// ```
-
Future<http.Response> fetchHandler(
-
String pathname, {
-
String method = 'GET',
-
Map<String, String>? headers,
-
dynamic body,
-
}) async {
-
// Try to refresh the token if it's known to be expired
-
final tokenSet = await _getTokenSet('auto');
-
-
final initialUrl = Uri.parse(tokenSet.aud).resolve(pathname);
-
final initialAuth = '${tokenSet.tokenType} ${tokenSet.accessToken}';
-
-
final initialHeaders = <String, String>{
-
...?headers,
-
'Authorization': initialAuth,
-
};
-
-
// Make request with DPoP - the interceptor will automatically add DPoP header
-
final initialResponse = await _makeDpopRequest(
-
initialUrl,
-
method: method,
-
headers: initialHeaders,
-
body: body,
-
);
-
-
// If the token is not expired, we don't need to refresh it
-
if (!_isInvalidTokenResponse(initialResponse)) {
-
return initialResponse;
-
}
-
-
// Token is invalid, try to refresh
-
TokenSet tokenSetFresh;
-
try {
-
// Force a refresh
-
tokenSetFresh = await _getTokenSet(true);
-
} catch (err) {
-
// If refresh fails, return the original response
-
return initialResponse;
-
}
-
-
// Retry with fresh token
-
final finalAuth = '${tokenSetFresh.tokenType} ${tokenSetFresh.accessToken}';
-
final finalUrl = Uri.parse(tokenSetFresh.aud).resolve(pathname);
-
-
final finalHeaders = <String, String>{
-
...?headers,
-
'Authorization': finalAuth,
-
};
-
-
final finalResponse = await _makeDpopRequest(
-
finalUrl,
-
method: method,
-
headers: finalHeaders,
-
body: body,
-
);
-
-
// The token was successfully refreshed, but is still not accepted by the
-
// resource server. This might be due to the resource server not accepting
-
// credentials from the authorization server (e.g. because some migration
-
// occurred). Any ways, there is no point in keeping the session.
-
if (_isInvalidTokenResponse(finalResponse)) {
-
await sessionGetter.delStored(sub, TokenInvalidError(sub));
-
}
-
-
return finalResponse;
-
}
-
-
/// Makes an HTTP request with DPoP authentication.
-
///
-
/// Uses Dio with DPoP interceptor which automatically adds:
-
/// - DPoP header with proof JWT
-
/// - Access token hash (ath) binding
-
///
-
/// Throws [DioException] for network errors, timeouts, and cancellations.
-
Future<http.Response> _makeDpopRequest(
-
Uri url, {
-
required String method,
-
Map<String, String>? headers,
-
dynamic body,
-
}) async {
-
try {
-
// Make request with Dio - interceptor will add DPoP header
-
final response = await _dio.requestUri(
-
url,
-
options: Options(
-
method: method,
-
headers: headers,
-
responseType: ResponseType.bytes, // Get raw bytes for compatibility
-
validateStatus: (status) => true, // Don't throw on any status code
-
),
-
data: body,
-
);
-
-
// Convert Dio Response to http.Response for compatibility
-
return http.Response.bytes(
-
response.data as List<int>,
-
response.statusCode!,
-
headers: response.headers.map.map(
-
(key, value) => MapEntry(key, value.join(', ')),
-
),
-
reasonPhrase: response.statusMessage,
-
);
-
} on DioException catch (e) {
-
// If we have a response (4xx/5xx), convert it to http.Response
-
if (e.response != null) {
-
final errorResponse = e.response!;
-
return http.Response.bytes(
-
errorResponse.data is List<int>
-
? errorResponse.data as List<int>
-
: (errorResponse.data?.toString() ?? '').codeUnits,
-
errorResponse.statusCode!,
-
headers: errorResponse.headers.map.map(
-
(key, value) => MapEntry(key, value.join(', ')),
-
),
-
reasonPhrase: errorResponse.statusMessage,
-
);
-
}
-
// Network errors, timeouts, cancellations - rethrow
-
rethrow;
-
}
-
}
-
-
/// Checks if a response indicates an invalid token.
-
///
-
/// See:
-
/// - https://datatracker.ietf.org/doc/html/rfc6750#section-3
-
/// - https://datatracker.ietf.org/doc/html/rfc9449#name-resource-server-provided-no
-
bool _isInvalidTokenResponse(http.Response response) {
-
if (response.statusCode != 401) return false;
-
-
final wwwAuth = response.headers['www-authenticate'];
-
return wwwAuth != null &&
-
(wwwAuth.startsWith('Bearer ') || wwwAuth.startsWith('DPoP ')) &&
-
wwwAuth.contains('error="invalid_token"');
-
}
-
-
/// Disposes of resources used by this session.
-
void dispose() {
-
_dio.close();
-
}
-
}
-42
packages/atproto_oauth_flutter/lib/src/session/session.dart
···
-
/// Session management layer for atproto OAuth.
-
///
-
/// This module provides session storage, retrieval, and lifecycle management
-
/// for OAuth sessions. It includes:
-
///
-
/// - [StateStore] - Stores ephemeral OAuth state during authorization
-
/// - [SessionStore] - Stores persistent session data
-
/// - [Session] - Represents an authenticated session with tokens
-
/// - [TokenSet] - Contains OAuth tokens and metadata
-
/// - [OAuthSession] - High-level API for authenticated requests
-
/// - [SessionGetter] - Manages session caching and token refresh
-
///
-
/// Example:
-
/// ```dart
-
/// // Create a session store implementation
-
/// final sessionStore = MySessionStore();
-
///
-
/// // Create a session getter
-
/// final sessionGetter = SessionGetter(
-
/// sessionStore: sessionStore,
-
/// serverFactory: serverFactory,
-
/// runtime: runtime,
-
/// );
-
///
-
/// // Get a session (automatically refreshes if needed)
-
/// final session = await sessionGetter.getSession('did:plc:abc123');
-
///
-
/// // Create an OAuthSession for making requests
-
/// final oauthSession = OAuthSession(
-
/// server: server,
-
/// sub: 'did:plc:abc123',
-
/// sessionGetter: sessionGetter,
-
/// );
-
///
-
/// // Make authenticated requests
-
/// final response = await oauthSession.fetchHandler('/api/posts');
-
/// ```
-
library;
-
-
export 'state_store.dart';
-
export 'oauth_session.dart';
-
export 'session_getter.dart';
-644
packages/atproto_oauth_flutter/lib/src/session/session_getter.dart
···
-
import 'dart:async';
-
import 'dart:convert';
-
import 'dart:math' as math;
-
-
import '../errors/auth_method_unsatisfiable_error.dart';
-
import '../errors/token_invalid_error.dart';
-
import '../errors/token_refresh_error.dart';
-
import '../errors/token_revoked_error.dart';
-
import '../oauth/client_auth.dart' show ClientAuthMethod;
-
import '../oauth/oauth_server_agent.dart';
-
import '../oauth/oauth_server_factory.dart';
-
import '../platform/flutter_key.dart';
-
import '../runtime/runtime.dart';
-
import '../util.dart';
-
import 'oauth_session.dart';
-
-
/// Options for getting a cached value.
-
class GetCachedOptions {
-
/// Cancellation token for aborting the operation
-
final CancellationToken? signal;
-
-
/// Do not use the cache to get the value. Always get a new value.
-
final bool? noCache;
-
-
/// Allow returning stale values from the cache.
-
final bool? allowStale;
-
-
const GetCachedOptions({this.signal, this.noCache, this.allowStale});
-
}
-
-
/// Abstract storage interface for values.
-
///
-
/// This is a generic key-value store interface.
-
abstract class SimpleStore<K, V> {
-
/// Gets a value from the store.
-
///
-
/// Returns `null` if the key doesn't exist.
-
Future<V?> get(K key, {CancellationToken? signal});
-
-
/// Sets a value in the store.
-
Future<void> set(K key, V value);
-
-
/// Deletes a value from the store.
-
Future<void> del(K key);
-
-
/// Optionally clears all values from the store.
-
Future<void> clear() async {}
-
}
-
-
/// Type alias for session storage
-
typedef SessionStore = SimpleStore<String, Session>;
-
-
/// Details of a session update event.
-
class SessionUpdatedEvent {
-
/// The subject (user's DID)
-
final String sub;
-
-
/// The DPoP key
-
final Map<String, dynamic> dpopKey;
-
-
/// The authentication method
-
final String? authMethod;
-
-
/// The token set
-
final TokenSet tokenSet;
-
-
const SessionUpdatedEvent({
-
required this.sub,
-
required this.dpopKey,
-
this.authMethod,
-
required this.tokenSet,
-
});
-
}
-
-
/// Details of a session deletion event.
-
class SessionDeletedEvent {
-
/// The subject (user's DID)
-
final String sub;
-
-
/// The cause of deletion
-
final Object cause;
-
-
const SessionDeletedEvent({required this.sub, required this.cause});
-
}
-
-
/// Manages session retrieval, caching, and refreshing.
-
///
-
/// The SessionGetter wraps a session store and provides:
-
/// - Automatic token refresh when tokens are stale/expired
-
/// - Caching to avoid redundant refresh operations
-
/// - Events for session updates and deletions
-
/// - Concurrency control to prevent multiple simultaneous refreshes
-
///
-
/// This is a critical component that ensures at most one token refresh
-
/// is happening at a time for a given user, even across multiple tabs
-
/// or app instances.
-
///
-
/// Example:
-
/// ```dart
-
/// final sessionGetter = SessionGetter(
-
/// sessionStore: mySessionStore,
-
/// serverFactory: myServerFactory,
-
/// runtime: myRuntime,
-
/// );
-
///
-
/// // Listen for session updates
-
/// sessionGetter.onUpdated.listen((event) {
-
/// print('Session updated for ${event.sub}');
-
/// });
-
///
-
/// // Listen for session deletions
-
/// sessionGetter.onDeleted.listen((event) {
-
/// print('Session deleted for ${event.sub}: ${event.cause}');
-
/// });
-
///
-
/// // Get a session (automatically refreshes if expired)
-
/// final session = await sessionGetter.getSession('did:plc:abc123');
-
///
-
/// // Force refresh
-
/// final freshSession = await sessionGetter.getSession('did:plc:abc123', true);
-
/// ```
-
class SessionGetter extends CachedGetter<AtprotoDid, Session> {
-
final OAuthServerFactory _serverFactory;
-
final Runtime _runtime;
-
-
final _eventTarget = CustomEventTarget<Map<String, dynamic>>();
-
final _updatedController = StreamController<SessionUpdatedEvent>.broadcast();
-
final _deletedController = StreamController<SessionDeletedEvent>.broadcast();
-
-
/// Stream of session update events.
-
Stream<SessionUpdatedEvent> get onUpdated => _updatedController.stream;
-
-
/// Stream of session deletion events.
-
Stream<SessionDeletedEvent> get onDeleted => _deletedController.stream;
-
-
SessionGetter({
-
required super.sessionStore,
-
required OAuthServerFactory serverFactory,
-
required Runtime runtime,
-
}) : _serverFactory = serverFactory,
-
_runtime = runtime,
-
super(
-
getter: null, // Will be set in _createGetter
-
options: CachedGetterOptions(
-
isStale: (sub, session) {
-
final tokenSet = session.tokenSet;
-
if (tokenSet.expiresAt == null) return false;
-
-
final expiresAt = DateTime.parse(tokenSet.expiresAt!);
-
final now = DateTime.now();
-
-
// Add some lee way to ensure the token is not expired when it
-
// reaches the server (10 seconds)
-
// Add some randomness to reduce the chances of multiple
-
// instances trying to refresh the token at the same time (0-30 seconds)
-
final buffer = Duration(
-
milliseconds:
-
10000 + (math.Random().nextDouble() * 30000).toInt(),
-
);
-
-
return expiresAt.isBefore(now.add(buffer));
-
},
-
onStoreError: (err, sub, session) async {
-
if (err is! AuthMethodUnsatisfiableError) {
-
// If the error was an AuthMethodUnsatisfiableError, there is no
-
// point in trying to call `fromIssuer`.
-
try {
-
// Parse authMethod
-
final authMethodValue = session.authMethod;
-
final authMethod =
-
authMethodValue is Map<String, dynamic>
-
? ClientAuthMethod.fromJson(authMethodValue)
-
: (authMethodValue as String?) ?? 'legacy';
-
-
// Restore DPoP key from session for revocation
-
// CRITICAL FIX: Use the stored key instead of generating a new one
-
// This ensures DPoP proofs match the token binding
-
final dpopKey = FlutterKey.fromJwk(
-
session.dpopKey as Map<String, dynamic>,
-
);
-
-
// If the token data cannot be stored, let's revoke it
-
final server = await serverFactory.fromIssuer(
-
session.tokenSet.iss,
-
authMethod,
-
dpopKey,
-
);
-
await server.revoke(
-
session.tokenSet.refreshToken ??
-
session.tokenSet.accessToken,
-
);
-
} catch (_) {
-
// Let the original error propagate
-
}
-
}
-
-
throw err;
-
},
-
deleteOnError: (err) async {
-
return err is TokenRefreshError ||
-
err is TokenRevokedError ||
-
err is TokenInvalidError ||
-
err is AuthMethodUnsatisfiableError;
-
},
-
),
-
) {
-
// Set the getter function after construction
-
_getter = _createGetter();
-
}
-
-
/// Creates the getter function for refreshing sessions.
-
Future<Session> Function(AtprotoDid, GetCachedOptions, Session?)
-
_createGetter() {
-
return (sub, options, storedSession) async {
-
// There needs to be a previous session to be able to refresh. If
-
// storedSession is null, it means that the store does not contain
-
// a session for the given sub.
-
if (storedSession == null) {
-
// Because the session is not in the store, delStored() method
-
// will not be called by the CachedGetter class (because there is
-
// nothing to delete). This would typically happen if there is no
-
// synchronization mechanism between instances of this class. Let's
-
// make sure an event is dispatched here if this occurs.
-
const msg = 'The session was deleted by another process';
-
final cause = TokenRefreshError(sub, msg);
-
_dispatchDeletedEvent(sub, cause);
-
throw cause;
-
}
-
-
// From this point forward, throwing a TokenRefreshError will result in
-
// delStored() being called, resulting in an event being dispatched,
-
// even if the session was removed from the store through a concurrent
-
// access (which, normally, should not happen if a proper runtime lock
-
// was provided).
-
-
// authMethod can be a Map (serialized ClientAuthMethod) or String ('legacy')
-
final authMethodValue = storedSession.authMethod;
-
final authMethod =
-
authMethodValue is Map<String, dynamic>
-
? ClientAuthMethod.fromJson(authMethodValue)
-
: (authMethodValue as String?) ?? 'legacy';
-
final tokenSet = storedSession.tokenSet;
-
-
if (sub != tokenSet.sub) {
-
// Fool-proofing (e.g. against invalid session storage)
-
throw TokenRefreshError(sub, 'Stored session sub mismatch');
-
}
-
-
if (tokenSet.refreshToken == null) {
-
throw TokenRefreshError(sub, 'No refresh token available');
-
}
-
-
// Since refresh tokens can only be used once, we might run into
-
// concurrency issues if multiple instances (e.g. browser tabs) are
-
// trying to refresh the same token simultaneously. The chances of this
-
// happening when multiple instances are started simultaneously is
-
// reduced by randomizing the expiry time (see isStale above). The
-
// best solution is to use a mutex/lock to ensure that only one instance
-
// is refreshing the token at a time (runtime.usingLock) but that is not
-
// always possible. If no lock implementation is provided, we will use
-
// the store to check if a concurrent refresh occurred.
-
-
// Restore dpopKey from stored private JWK with error handling
-
// CRITICAL FIX: Use the stored key instead of generating a new one
-
// This ensures DPoP proofs match the token binding during refresh
-
final FlutterKey dpopKey;
-
try {
-
dpopKey = FlutterKey.fromJwk(
-
storedSession.dpopKey as Map<String, dynamic>,
-
);
-
} catch (e) {
-
// If key is corrupted, the session is unusable - force re-authentication
-
throw TokenRefreshError(
-
sub,
-
'Corrupted DPoP key in stored session: $e. Re-authentication required.',
-
);
-
}
-
-
final server = await _serverFactory.fromIssuer(
-
tokenSet.iss,
-
authMethod,
-
dpopKey,
-
);
-
-
// Because refresh tokens can only be used once, we must not use the
-
// "signal" to abort the refresh, or throw any abort error beyond this
-
// point. Any thrown error beyond this point will prevent the
-
// SessionGetter from obtaining, and storing, the new token set,
-
// effectively rendering the currently saved session unusable.
-
options.signal?.throwIfCancelled();
-
-
try {
-
final newTokenSet = await server.refresh(tokenSet);
-
-
if (sub != newTokenSet.sub) {
-
// The server returned another sub. Was the tokenSet manipulated?
-
throw TokenRefreshError(sub, 'Token set sub mismatch');
-
}
-
-
// CRITICAL FIX: Preserve the stored DPoP key (full private JWK)
-
// This ensures the same key is used across token refreshes
-
return Session(
-
dpopKey: storedSession.dpopKey,
-
tokenSet: newTokenSet,
-
authMethod: server.authMethod.toJson(),
-
);
-
} catch (cause) {
-
// If the refresh token is invalid, let's try to recover from
-
// concurrency issues, or make sure the session is deleted by throwing
-
// a TokenRefreshError.
-
if (cause is OAuthResponseError &&
-
cause.status == 400 &&
-
cause.error == 'invalid_grant') {
-
// In case there is no lock implementation in the runtime, we will
-
// wait for a short time to give the other concurrent instances a
-
// chance to finish their refreshing of the token. If a concurrent
-
// refresh did occur, we will pretend that this one succeeded.
-
if (!_runtime.hasImplementationLock) {
-
await Future.delayed(Duration(seconds: 1));
-
-
final stored = await getStored(sub);
-
if (stored == null) {
-
// A concurrent refresh occurred and caused the session to be
-
// deleted (for a reason we can't know at this point).
-
-
// Using a distinct error message mainly for debugging
-
// purposes. Also, throwing a TokenRefreshError to trigger
-
// deletion through the deleteOnError callback.
-
const msg = 'The session was deleted by another process';
-
throw TokenRefreshError(sub, msg, cause: cause);
-
} else if (stored.tokenSet.accessToken != tokenSet.accessToken ||
-
stored.tokenSet.refreshToken != tokenSet.refreshToken) {
-
// A concurrent refresh occurred. Pretend this one succeeded.
-
return stored;
-
} else {
-
// There were no concurrent refresh. The token is (likely)
-
// simply no longer valid.
-
}
-
}
-
-
// Make sure the session gets deleted from the store
-
final msg = cause.errorDescription ?? 'The session was revoked';
-
throw TokenRefreshError(sub, msg, cause: cause);
-
}
-
-
// Re-throw the original exception if it wasn't an invalid_grant error
-
if (cause is Exception) {
-
throw cause;
-
} else {
-
throw Exception('Token refresh failed: $cause');
-
}
-
}
-
};
-
}
-
-
@override
-
Future<void> setStored(String key, Session value) async {
-
// Prevent tampering with the stored value
-
if (key != value.tokenSet.sub) {
-
throw TypeError();
-
}
-
-
await super.setStored(key, value);
-
-
// Serialize authMethod to String for the event
-
// authMethod can be Map<String, dynamic>, String, or null
-
String? authMethodString;
-
if (value.authMethod is Map) {
-
authMethodString = jsonEncode(value.authMethod);
-
} else if (value.authMethod is String) {
-
authMethodString = value.authMethod as String;
-
} else {
-
authMethodString = null;
-
}
-
-
_dispatchUpdatedEvent(key, value.dpopKey, authMethodString, value.tokenSet);
-
}
-
-
@override
-
Future<void> delStored(AtprotoDid key, [Object? cause]) async {
-
await super.delStored(key, cause);
-
_dispatchDeletedEvent(key, cause ?? Exception('Session deleted'));
-
}
-
-
/// Gets a session, optionally refreshing it.
-
///
-
/// Parameters:
-
/// - [sub]: The subject (user's DID)
-
/// - [refresh]: When `true`, forces a token refresh even if not expired.
-
/// When `false`, uses cached tokens even if expired.
-
/// When `'auto'`, refreshes only if expired (default).
-
Future<Session> getSession(AtprotoDid sub, [dynamic refresh = 'auto']) {
-
return get(
-
sub,
-
GetCachedOptions(noCache: refresh == true, allowStale: refresh == false),
-
);
-
}
-
-
@override
-
Future<Session> get(AtprotoDid key, [GetCachedOptions? options]) async {
-
final session = await _runtime.usingLock(
-
'@atproto-oauth-client-$key',
-
() async {
-
// Make sure, even if there is no signal in the options, that the
-
// request will be cancelled after at most 30 seconds.
-
final timeoutToken = CancellationToken();
-
final timeoutTimer = Timer(Duration(seconds: 30), () => timeoutToken.cancel());
-
-
final combinedSignal =
-
options?.signal != null
-
? combineSignals([options!.signal, timeoutToken])
-
: CombinedCancellationToken([timeoutToken]);
-
-
try {
-
return await super.get(
-
key,
-
GetCachedOptions(
-
signal: CancellationToken(), // Use combined signal
-
noCache: options?.noCache,
-
allowStale: options?.allowStale,
-
),
-
);
-
} finally {
-
timeoutTimer.cancel(); // Cancel timer before disposing token
-
combinedSignal.dispose();
-
timeoutToken.dispose();
-
}
-
},
-
);
-
-
if (key != session.tokenSet.sub) {
-
// Fool-proofing (e.g. against invalid session storage)
-
throw Exception('Token set does not match the expected sub');
-
}
-
-
return session;
-
}
-
-
void _dispatchUpdatedEvent(
-
String sub,
-
Map<String, dynamic> dpopKey,
-
String? authMethod,
-
TokenSet tokenSet,
-
) {
-
final event = SessionUpdatedEvent(
-
sub: sub,
-
dpopKey: dpopKey,
-
authMethod: authMethod,
-
tokenSet: tokenSet,
-
);
-
-
_updatedController.add(event);
-
_eventTarget.dispatchCustomEvent('updated', event);
-
}
-
-
void _dispatchDeletedEvent(String sub, Object cause) {
-
final event = SessionDeletedEvent(sub: sub, cause: cause);
-
-
_deletedController.add(event);
-
_eventTarget.dispatchCustomEvent('deleted', event);
-
}
-
-
/// Disposes of resources used by this session getter.
-
void dispose() {
-
_updatedController.close();
-
_deletedController.close();
-
_eventTarget.dispose();
-
}
-
}
-
-
/// Placeholder for OAuthResponseError
-
/// Will be implemented in later chunks
-
class OAuthResponseError implements Exception {
-
final int status;
-
final String? error;
-
final String? errorDescription;
-
-
OAuthResponseError({required this.status, this.error, this.errorDescription});
-
}
-
-
/// Options for the CachedGetter.
-
class CachedGetterOptions<K, V> {
-
/// Function to determine if a cached value is stale
-
final bool Function(K key, V value)? isStale;
-
-
/// Function called when storing a value fails
-
final Future<void> Function(Object err, K key, V value)? onStoreError;
-
-
/// Function to determine if a value should be deleted on error
-
final Future<bool> Function(Object err)? deleteOnError;
-
-
const CachedGetterOptions({
-
this.isStale,
-
this.onStoreError,
-
this.deleteOnError,
-
});
-
}
-
-
/// A pending item in the cache.
-
class _PendingItem<V> {
-
final Future<({V value, bool isFresh})> future;
-
-
_PendingItem(this.future);
-
}
-
-
/// Wrapper utility that uses a store to speed up the retrieval of values.
-
///
-
/// The CachedGetter ensures that at most one fresh call is ever being made
-
/// for a given key. It also contains logic for reading from the cache which,
-
/// if the cache is based on localStorage/indexedDB, will sync across multiple
-
/// tabs (for a given key).
-
///
-
/// This is an abstract base class. Subclasses should provide the getter
-
/// function and any additional logic.
-
class CachedGetter<K, V> {
-
final SimpleStore<K, V> _store;
-
final CachedGetterOptions<K, V> _options;
-
final Map<K, _PendingItem<V>> _pending = {};
-
-
late Future<V> Function(K, GetCachedOptions, V?) _getter;
-
-
CachedGetter({
-
required SimpleStore<K, V> sessionStore,
-
required Future<V> Function(K, GetCachedOptions, V?)? getter,
-
required CachedGetterOptions<K, V> options,
-
}) : _store = sessionStore,
-
_options = options {
-
if (getter != null) {
-
_getter = getter;
-
}
-
}
-
-
Future<V> get(K key, [GetCachedOptions? options]) async {
-
options ??= GetCachedOptions();
-
final signal = options.signal;
-
final noCache = options.noCache ?? false;
-
final allowStale = options.allowStale ?? false;
-
-
signal?.throwIfCancelled();
-
-
final isStale = _options.isStale;
-
final deleteOnError = _options.deleteOnError;
-
-
// Determine if a stored value can be used
-
bool allowStored(V value) {
-
if (noCache) return false; // Never allow stored values
-
if (allowStale || isStale == null) return true; // Always allow
-
return !isStale(key, value); // Check if stale
-
}
-
-
// As long as concurrent requests are made for the same key, only one
-
// request will be made to the getStored & getter functions at a time.
-
_PendingItem<V>? previousExecutionFlow;
-
while ((previousExecutionFlow = _pending[key]) != null) {
-
try {
-
final result = await previousExecutionFlow!.future;
-
final isFresh = result.isFresh;
-
final value = result.value;
-
-
// Use the concurrent request's result if it is fresh
-
if (isFresh) return value;
-
// Use the concurrent request's result if not fresh (loaded from the
-
// store), and matches the conditions for using a stored value.
-
if (allowStored(value)) return value;
-
} catch (_) {
-
// Ignore errors from previous execution flows (they will have been
-
// propagated by that flow).
-
}
-
-
// Break the loop if the signal was cancelled
-
signal?.throwIfCancelled();
-
}
-
-
final currentExecutionFlow = _PendingItem<V>(
-
Future(() async {
-
final storedValue = await getStored(key, signal: signal);
-
-
if (storedValue != null && allowStored(storedValue)) {
-
// Use the stored value as return value for the current execution
-
// flow. Notify other concurrent execution flows that we got a value,
-
// but that it came from the store (isFresh = false).
-
return (value: storedValue, isFresh: false);
-
}
-
-
return Future(() async {
-
return await _getter(key, options!, storedValue);
-
})
-
.catchError((err) async {
-
if (storedValue != null) {
-
try {
-
if (deleteOnError != null && await deleteOnError(err)) {
-
await delStored(key, err);
-
}
-
} catch (error) {
-
throw Exception('Error while deleting stored value: $error');
-
}
-
}
-
throw err;
-
})
-
.then((value) async {
-
// The value should be stored even if the signal was cancelled.
-
await setStored(key, value);
-
return (value: value, isFresh: true);
-
});
-
}).whenComplete(() {
-
_pending.remove(key);
-
}),
-
);
-
-
if (_pending.containsKey(key)) {
-
// This should never happen. There must not be any 'await'
-
// statement between this and the loop iteration check.
-
throw Exception('Concurrent request for the same key');
-
}
-
-
_pending[key] = currentExecutionFlow;
-
-
final result = await currentExecutionFlow.future;
-
return result.value;
-
}
-
-
Future<V?> getStored(K key, {CancellationToken? signal}) async {
-
try {
-
return await _store.get(key, signal: signal);
-
} catch (err) {
-
return null;
-
}
-
}
-
-
Future<void> setStored(K key, V value) async {
-
try {
-
await _store.set(key, value);
-
} catch (err) {
-
final onStoreError = _options.onStoreError;
-
if (onStoreError != null) {
-
await onStoreError(err, key, value);
-
}
-
}
-
}
-
-
Future<void> delStored(K key, [Object? cause]) async {
-
await _store.del(key);
-
}
-
}
-112
packages/atproto_oauth_flutter/lib/src/session/state_store.dart
···
-
/// Internal state data stored during OAuth authorization flow.
-
///
-
/// This contains ephemeral data needed to complete the OAuth flow,
-
/// such as PKCE code verifiers, state parameters, and nonces.
-
class InternalStateData {
-
/// The OAuth issuer URL
-
final String iss;
-
-
/// The DPoP key used for this authorization
-
final Map<String, dynamic> dpopKey;
-
-
/// Client authentication method (serialized as Map or String)
-
///
-
/// Can be:
-
/// - A Map containing {method: 'private_key_jwt', kid: '...'} for private key JWT
-
/// - A Map containing {method: 'none'} for no authentication
-
/// - A String 'legacy' for backwards compatibility
-
/// - null (defaults to 'legacy' when loading)
-
final dynamic authMethod;
-
-
/// PKCE code verifier for authorization code flow
-
final String? verifier;
-
-
/// The redirect URI used during authorization
-
/// MUST match exactly during token exchange
-
final String? redirectUri;
-
-
/// Application state to preserve across the OAuth flow
-
final String? appState;
-
-
const InternalStateData({
-
required this.iss,
-
required this.dpopKey,
-
this.authMethod,
-
this.verifier,
-
this.redirectUri,
-
this.appState,
-
});
-
-
/// Creates an instance from a JSON map.
-
factory InternalStateData.fromJson(Map<String, dynamic> json) {
-
return InternalStateData(
-
iss: json['iss'] as String,
-
dpopKey: json['dpopKey'] as Map<String, dynamic>,
-
authMethod: json['authMethod'], // Can be Map or String
-
verifier: json['verifier'] as String?,
-
redirectUri: json['redirectUri'] as String?,
-
appState: json['appState'] as String?,
-
);
-
}
-
-
/// Converts this instance to a JSON map.
-
Map<String, dynamic> toJson() {
-
final json = <String, dynamic>{'iss': iss, 'dpopKey': dpopKey};
-
-
if (authMethod != null) json['authMethod'] = authMethod;
-
if (verifier != null) json['verifier'] = verifier;
-
if (redirectUri != null) json['redirectUri'] = redirectUri;
-
if (appState != null) json['appState'] = appState;
-
-
return json;
-
}
-
}
-
-
/// Abstract storage interface for OAuth state data.
-
///
-
/// Implementations should store state data temporarily during the OAuth flow.
-
/// This data is typically short-lived and can be cleared after successful
-
/// authorization or timeout.
-
///
-
/// Example implementation using in-memory storage:
-
/// ```dart
-
/// class MemoryStateStore implements StateStore {
-
/// final Map<String, InternalStateData> _store = {};
-
///
-
/// @override
-
/// Future<InternalStateData?> get(String key) async => _store[key];
-
///
-
/// @override
-
/// Future<void> set(String key, InternalStateData data) async {
-
/// _store[key] = data;
-
/// }
-
///
-
/// @override
-
/// Future<void> del(String key) async {
-
/// _store.remove(key);
-
/// }
-
/// }
-
/// ```
-
abstract class StateStore {
-
/// Retrieves state data for the given key.
-
///
-
/// Returns `null` if no data exists for the key.
-
Future<InternalStateData?> get(String key);
-
-
/// Stores state data for the given key.
-
///
-
/// Overwrites any existing data for the key.
-
Future<void> set(String key, InternalStateData data);
-
-
/// Deletes state data for the given key.
-
///
-
/// Does nothing if no data exists for the key.
-
Future<void> del(String key);
-
-
/// Optionally clears all state data.
-
///
-
/// Implementations may choose not to implement this method.
-
Future<void> clear() async {
-
// Default implementation does nothing
-
}
-
}
-352
packages/atproto_oauth_flutter/lib/src/types.dart
···
-
// Note: These types are not prefixed with `OAuth` because they are not specific
-
// to OAuth. They are specific to this package. OAuth specific types will be in
-
// a separate oauth-types module or imported from an external package.
-
-
// TODO: These types currently reference schemas from @atproto/oauth-types which
-
// need to be ported to Dart. For now, we're using Map<String, dynamic> as placeholders.
-
// These will be replaced with proper typed classes once oauth-types is ported.
-
-
/// Options for initiating an authorization request.
-
///
-
/// Omits client_id, response_mode, response_type, login_hint,
-
/// code_challenge, and code_challenge_method from OAuthAuthorizationRequestParameters
-
/// as these are managed internally.
-
class AuthorizeOptions {
-
/// Optional URI to redirect to after authorization
-
final String? redirectUri;
-
-
/// Optional state parameter for CSRF protection
-
final String? state;
-
-
/// Optional scope parameter defining requested permissions
-
final String? scope;
-
-
/// Optional nonce parameter for replay protection
-
final String? nonce;
-
-
/// Optional DPoP JKT (JSON Web Key Thumbprint)
-
final String? dpopJkt;
-
-
/// Optional max age in seconds for authentication
-
final int? maxAge;
-
-
/// Optional claims parameter
-
final Map<String, dynamic>? claims;
-
-
/// Optional UI locales
-
final String? uiLocales;
-
-
/// Optional ID token hint
-
final String? idTokenHint;
-
-
/// Optional display mode
-
final String? display;
-
-
/// Optional prompt value
-
final String? prompt;
-
-
/// Optional authorization details
-
final Map<String, dynamic>? authorizationDetails;
-
-
const AuthorizeOptions({
-
this.redirectUri,
-
this.state,
-
this.scope,
-
this.nonce,
-
this.dpopJkt,
-
this.maxAge,
-
this.claims,
-
this.uiLocales,
-
this.idTokenHint,
-
this.display,
-
this.prompt,
-
this.authorizationDetails,
-
});
-
-
Map<String, dynamic> toJson() {
-
final map = <String, dynamic>{};
-
if (redirectUri != null) map['redirect_uri'] = redirectUri;
-
if (state != null) map['state'] = state;
-
if (scope != null) map['scope'] = scope;
-
if (nonce != null) map['nonce'] = nonce;
-
if (dpopJkt != null) map['dpop_jkt'] = dpopJkt;
-
if (maxAge != null) map['max_age'] = maxAge;
-
if (claims != null) map['claims'] = claims;
-
if (uiLocales != null) map['ui_locales'] = uiLocales;
-
if (idTokenHint != null) map['id_token_hint'] = idTokenHint;
-
if (display != null) map['display'] = display;
-
if (prompt != null) map['prompt'] = prompt;
-
if (authorizationDetails != null) {
-
map['authorization_details'] = authorizationDetails;
-
}
-
return map;
-
}
-
}
-
-
/// Options for handling OAuth callback.
-
class CallbackOptions {
-
/// Optional redirect URI that was used in the authorization request
-
final String? redirectUri;
-
-
const CallbackOptions({this.redirectUri});
-
-
Map<String, dynamic> toJson() {
-
final map = <String, dynamic>{};
-
if (redirectUri != null) map['redirect_uri'] = redirectUri;
-
return map;
-
}
-
}
-
-
/// Client metadata for OAuth configuration.
-
///
-
/// TODO: This extends the base oauthClientMetadataSchema with specific
-
/// client_id validation. Once oauth-types is ported, this will properly
-
/// validate client_id as either discoverable or loopback type.
-
class ClientMetadata {
-
/// Client identifier (either discoverable HTTPS URI or loopback URI)
-
final String? clientId;
-
-
/// Array of redirect URIs
-
final List<String> redirectUris;
-
-
/// Response types supported by the client
-
final List<String> responseTypes;
-
-
/// Grant types supported by the client
-
final List<String> grantTypes;
-
-
/// Optional scope
-
final String? scope;
-
-
/// Token endpoint authentication method
-
final String tokenEndpointAuthMethod;
-
-
/// Optional token endpoint authentication signing algorithm
-
final String? tokenEndpointAuthSigningAlg;
-
-
/// Optional userinfo signed response algorithm
-
final String? userinfoSignedResponseAlg;
-
-
/// Optional userinfo encrypted response algorithm
-
final String? userinfoEncryptedResponseAlg;
-
-
/// Optional JWKS URI
-
final String? jwksUri;
-
-
/// Optional JWKS
-
final Map<String, dynamic>? jwks;
-
-
/// Application type (web or native)
-
final String applicationType;
-
-
/// Subject type (public or pairwise)
-
final String subjectType;
-
-
/// Optional request object signing algorithm
-
final String? requestObjectSigningAlg;
-
-
/// Optional ID token signed response algorithm
-
final String? idTokenSignedResponseAlg;
-
-
/// Authorization signed response algorithm
-
final String authorizationSignedResponseAlg;
-
-
/// Optional authorization encrypted response encoding
-
final String? authorizationEncryptedResponseEnc;
-
-
/// Optional authorization encrypted response algorithm
-
final String? authorizationEncryptedResponseAlg;
-
-
/// Optional client name
-
final String? clientName;
-
-
/// Optional client URI
-
final String? clientUri;
-
-
/// Optional policy URI
-
final String? policyUri;
-
-
/// Optional terms of service URI
-
final String? tosUri;
-
-
/// Optional logo URI
-
final String? logoUri;
-
-
/// Optional default max age
-
final int? defaultMaxAge;
-
-
/// Optional require auth time
-
final bool? requireAuthTime;
-
-
/// Optional contact emails
-
final List<String>? contacts;
-
-
/// Optional TLS client certificate bound access tokens
-
final bool? tlsClientCertificateBoundAccessTokens;
-
-
/// Optional DPoP bound access tokens
-
final bool? dpopBoundAccessTokens;
-
-
/// Optional authorization details types
-
final List<String>? authorizationDetailsTypes;
-
-
const ClientMetadata({
-
this.clientId,
-
required this.redirectUris,
-
this.responseTypes = const ['code'],
-
this.grantTypes = const ['authorization_code'],
-
this.scope,
-
this.tokenEndpointAuthMethod = 'client_secret_basic',
-
this.tokenEndpointAuthSigningAlg,
-
this.userinfoSignedResponseAlg,
-
this.userinfoEncryptedResponseAlg,
-
this.jwksUri,
-
this.jwks,
-
this.applicationType = 'web',
-
this.subjectType = 'public',
-
this.requestObjectSigningAlg,
-
this.idTokenSignedResponseAlg,
-
this.authorizationSignedResponseAlg = 'RS256',
-
this.authorizationEncryptedResponseEnc,
-
this.authorizationEncryptedResponseAlg,
-
this.clientName,
-
this.clientUri,
-
this.policyUri,
-
this.tosUri,
-
this.logoUri,
-
this.defaultMaxAge,
-
this.requireAuthTime,
-
this.contacts,
-
this.tlsClientCertificateBoundAccessTokens,
-
this.dpopBoundAccessTokens,
-
this.authorizationDetailsTypes,
-
});
-
-
Map<String, dynamic> toJson() {
-
final map = <String, dynamic>{
-
'redirect_uris': redirectUris,
-
'response_types': responseTypes,
-
'grant_types': grantTypes,
-
'token_endpoint_auth_method': tokenEndpointAuthMethod,
-
'application_type': applicationType,
-
'subject_type': subjectType,
-
'authorization_signed_response_alg': authorizationSignedResponseAlg,
-
};
-
-
if (clientId != null) map['client_id'] = clientId;
-
if (scope != null) map['scope'] = scope;
-
if (tokenEndpointAuthSigningAlg != null) {
-
map['token_endpoint_auth_signing_alg'] = tokenEndpointAuthSigningAlg;
-
}
-
if (userinfoSignedResponseAlg != null) {
-
map['userinfo_signed_response_alg'] = userinfoSignedResponseAlg;
-
}
-
if (userinfoEncryptedResponseAlg != null) {
-
map['userinfo_encrypted_response_alg'] = userinfoEncryptedResponseAlg;
-
}
-
if (jwksUri != null) map['jwks_uri'] = jwksUri;
-
if (jwks != null) map['jwks'] = jwks;
-
if (requestObjectSigningAlg != null) {
-
map['request_object_signing_alg'] = requestObjectSigningAlg;
-
}
-
if (idTokenSignedResponseAlg != null) {
-
map['id_token_signed_response_alg'] = idTokenSignedResponseAlg;
-
}
-
if (authorizationEncryptedResponseEnc != null) {
-
map['authorization_encrypted_response_enc'] =
-
authorizationEncryptedResponseEnc;
-
}
-
if (authorizationEncryptedResponseAlg != null) {
-
map['authorization_encrypted_response_alg'] =
-
authorizationEncryptedResponseAlg;
-
}
-
if (clientName != null) map['client_name'] = clientName;
-
if (clientUri != null) map['client_uri'] = clientUri;
-
if (policyUri != null) map['policy_uri'] = policyUri;
-
if (tosUri != null) map['tos_uri'] = tosUri;
-
if (logoUri != null) map['logo_uri'] = logoUri;
-
if (defaultMaxAge != null) map['default_max_age'] = defaultMaxAge;
-
if (requireAuthTime != null) map['require_auth_time'] = requireAuthTime;
-
if (contacts != null) map['contacts'] = contacts;
-
if (tlsClientCertificateBoundAccessTokens != null) {
-
map['tls_client_certificate_bound_access_tokens'] =
-
tlsClientCertificateBoundAccessTokens;
-
}
-
if (dpopBoundAccessTokens != null) {
-
map['dpop_bound_access_tokens'] = dpopBoundAccessTokens;
-
}
-
if (authorizationDetailsTypes != null) {
-
map['authorization_details_types'] = authorizationDetailsTypes;
-
}
-
-
return map;
-
}
-
-
factory ClientMetadata.fromJson(Map<String, dynamic> json) {
-
return ClientMetadata(
-
clientId: json['client_id'] as String?,
-
redirectUris:
-
json['redirect_uris'] != null
-
? (json['redirect_uris'] as List<dynamic>)
-
.map((e) => e as String)
-
.toList()
-
: [],
-
responseTypes:
-
json['response_types'] != null
-
? (json['response_types'] as List<dynamic>)
-
.map((e) => e as String)
-
.toList()
-
: const ['code'],
-
grantTypes:
-
json['grant_types'] != null
-
? (json['grant_types'] as List<dynamic>)
-
.map((e) => e as String)
-
.toList()
-
: const ['authorization_code'],
-
scope: json['scope'] as String?,
-
tokenEndpointAuthMethod:
-
json['token_endpoint_auth_method'] as String? ??
-
'client_secret_basic',
-
tokenEndpointAuthSigningAlg:
-
json['token_endpoint_auth_signing_alg'] as String?,
-
userinfoSignedResponseAlg:
-
json['userinfo_signed_response_alg'] as String?,
-
userinfoEncryptedResponseAlg:
-
json['userinfo_encrypted_response_alg'] as String?,
-
jwksUri: json['jwks_uri'] as String?,
-
jwks: json['jwks'] as Map<String, dynamic>?,
-
applicationType: json['application_type'] as String? ?? 'web',
-
subjectType: json['subject_type'] as String? ?? 'public',
-
requestObjectSigningAlg: json['request_object_signing_alg'] as String?,
-
idTokenSignedResponseAlg: json['id_token_signed_response_alg'] as String?,
-
authorizationSignedResponseAlg:
-
json['authorization_signed_response_alg'] as String? ?? 'RS256',
-
authorizationEncryptedResponseEnc:
-
json['authorization_encrypted_response_enc'] as String?,
-
authorizationEncryptedResponseAlg:
-
json['authorization_encrypted_response_alg'] as String?,
-
clientName: json['client_name'] as String?,
-
clientUri: json['client_uri'] as String?,
-
policyUri: json['policy_uri'] as String?,
-
tosUri: json['tos_uri'] as String?,
-
logoUri: json['logo_uri'] as String?,
-
defaultMaxAge: json['default_max_age'] as int?,
-
requireAuthTime: json['require_auth_time'] as bool?,
-
contacts:
-
json['contacts'] != null
-
? (json['contacts'] as List<dynamic>)
-
.map((e) => e as String)
-
.toList()
-
: null,
-
tlsClientCertificateBoundAccessTokens:
-
json['tls_client_certificate_bound_access_tokens'] as bool?,
-
dpopBoundAccessTokens: json['dpop_bound_access_tokens'] as bool?,
-
authorizationDetailsTypes:
-
json['authorization_details_types'] != null
-
? (json['authorization_details_types'] as List<dynamic>)
-
.map((e) => e as String)
-
.toList()
-
: null,
-
);
-
}
-
}
-195
packages/atproto_oauth_flutter/lib/src/util.dart
···
-
import 'dart:async';
-
-
/// Returns the input if it's a String, otherwise returns null.
-
String? ifString<V>(V v) => v is String ? v : null;
-
-
/// Extracts the MIME type from Content-Type header.
-
///
-
/// Example: "application/json; charset=utf-8" -> "application/json"
-
String? contentMime(Map<String, String> headers) {
-
final contentType = headers['content-type'];
-
if (contentType == null) return null;
-
return contentType.split(';')[0].trim();
-
}
-
-
/// Event detail map for custom event handling.
-
///
-
/// This is a simplified version of TypeScript's CustomEvent pattern,
-
/// adapted for Dart using StreamController and typed events.
-
///
-
/// Example:
-
/// ```dart
-
/// final target = CustomEventTarget();
-
/// final subscription = target.addEventListener('myEvent', (String detail) {
-
/// print('Received: $detail');
-
/// });
-
///
-
/// // Later, to remove the listener:
-
/// subscription.cancel();
-
/// ```
-
class CustomEventTarget<EventDetailMap> {
-
final Map<String, StreamController<dynamic>> _controllers = {};
-
-
/// Add an event listener for a specific event type.
-
///
-
/// Returns a [StreamSubscription] that can be cancelled to remove the listener.
-
///
-
/// Throws [TypeError] if an event type is already registered with a different type parameter.
-
///
-
/// Example:
-
/// ```dart
-
/// final subscription = target.addEventListener('event', (detail) => print(detail));
-
/// subscription.cancel(); // Remove this specific listener
-
/// ```
-
StreamSubscription<T> addEventListener<T>(
-
String type,
-
void Function(T detail) callback,
-
) {
-
final existingController = _controllers[type];
-
-
// Check if a controller already exists with a different type
-
if (existingController != null &&
-
existingController is! StreamController<T>) {
-
throw TypeError();
-
}
-
-
final controller =
-
_controllers.putIfAbsent(type, () => StreamController<T>.broadcast())
-
as StreamController<T>;
-
-
return controller.stream.listen(callback);
-
}
-
-
/// Dispatch a custom event with detail data.
-
///
-
/// Returns true if the event was dispatched successfully.
-
bool dispatchCustomEvent<T>(String type, T detail) {
-
final controller = _controllers[type];
-
if (controller == null) return false;
-
-
(controller as StreamController<T>).add(detail);
-
return true;
-
}
-
-
/// Dispose of all stream controllers.
-
///
-
/// Call this when the event target is no longer needed to prevent memory leaks.
-
void dispose() {
-
for (final controller in _controllers.values) {
-
controller.close();
-
}
-
_controllers.clear();
-
}
-
}
-
-
/// Combines multiple cancellation tokens into a single cancellable operation.
-
///
-
/// This is a Dart adaptation of the TypeScript combineSignals function.
-
/// Since Dart doesn't have AbortSignal/AbortController, we use CancellationToken
-
/// pattern with StreamController.
-
///
-
/// The returned controller will be cancelled if any of the input tokens are cancelled.
-
class CombinedCancellationToken {
-
final StreamController<void> _controller = StreamController<void>.broadcast();
-
final List<StreamSubscription<void>> _subscriptions = [];
-
bool _isCancelled = false;
-
Object? _reason;
-
-
CombinedCancellationToken(List<CancellationToken?> tokens) {
-
for (final token in tokens) {
-
if (token != null) {
-
if (token.isCancelled) {
-
cancel(Exception('Operation was cancelled: ${token.reason}'));
-
return;
-
}
-
-
final subscription = token.stream.listen((_) {
-
cancel(Exception('Operation was cancelled: ${token.reason}'));
-
});
-
_subscriptions.add(subscription);
-
}
-
}
-
}
-
-
/// Whether this operation has been cancelled.
-
bool get isCancelled => _isCancelled;
-
-
/// The reason for cancellation, if any.
-
Object? get reason => _reason;
-
-
/// Stream that emits when the operation is cancelled.
-
Stream<void> get stream => _controller.stream;
-
-
/// Cancel the operation with an optional reason.
-
void cancel([Object? reason]) {
-
if (_isCancelled) return;
-
-
_isCancelled = true;
-
_reason = reason ?? Exception('Operation was cancelled');
-
-
_controller.add(null);
-
dispose();
-
}
-
-
/// Clean up resources.
-
void dispose() {
-
for (final subscription in _subscriptions) {
-
subscription.cancel();
-
}
-
_subscriptions.clear();
-
_controller.close();
-
}
-
}
-
-
/// Represents a cancellable operation.
-
///
-
/// This is a Dart equivalent of AbortSignal in JavaScript.
-
class CancellationToken {
-
final StreamController<void> _controller = StreamController<void>.broadcast();
-
bool _isCancelled = false;
-
Object? _reason;
-
-
CancellationToken();
-
-
/// Whether this operation has been cancelled.
-
bool get isCancelled => _isCancelled;
-
-
/// The reason for cancellation, if any.
-
Object? get reason => _reason;
-
-
/// Stream that emits when the operation is cancelled.
-
Stream<void> get stream => _controller.stream;
-
-
/// Cancel the operation with an optional reason.
-
void cancel([Object? reason]) {
-
if (_isCancelled) return;
-
-
_isCancelled = true;
-
_reason = reason ?? Exception('Operation was cancelled');
-
-
// Only add to stream if not already closed
-
if (!_controller.isClosed) {
-
_controller.add(null);
-
}
-
}
-
-
/// Throw an exception if the operation has been cancelled.
-
void throwIfCancelled() {
-
if (_isCancelled) {
-
throw _reason ?? Exception('Operation was cancelled');
-
}
-
}
-
-
/// Dispose of the stream controller.
-
void dispose() {
-
_controller.close();
-
}
-
}
-
-
/// Combines multiple cancellation tokens into a single token.
-
///
-
/// If any of the input tokens are cancelled, the returned token will also be cancelled.
-
/// The returned token should be disposed when no longer needed.
-
CombinedCancellationToken combineSignals(List<CancellationToken?> signals) {
-
return CombinedCancellationToken(signals);
-
}
-100
packages/atproto_oauth_flutter/lib/src/utils/lock.dart
···
-
import 'dart:async';
-
-
import '../runtime/runtime_implementation.dart';
-
-
/// A map storing active locks by name.
-
///
-
/// Each lock is represented as a Future that completes when the lock is released.
-
/// This allows queuing of operations waiting for the same lock.
-
final Map<Object, Future<void>> _locks = {};
-
-
/// Acquires a lock for the given name.
-
///
-
/// Returns a function that releases the lock when called.
-
/// The lock is automatically added to the queue of pending operations.
-
///
-
/// This implements a fair (FIFO) mutex pattern where operations are executed
-
/// in the order they acquire the lock.
-
Future<void Function()> _acquireLocalLock(Object name) {
-
final completer = Completer<void Function()>();
-
-
// Get the previous lock in the queue (or a resolved promise if none)
-
final prev = _locks[name] ?? Future.value();
-
-
// Create a completer for the release function
-
final releaseCompleter = Completer<void>();
-
-
// Chain onto the previous lock
-
final next = prev.then((_) {
-
// This runs when we've acquired the lock
-
return releaseCompleter.future;
-
});
-
-
// Store our lock as the new tail of the queue
-
_locks[name] = next;
-
-
// Resolve the acquire promise with the release function
-
prev.then((_) {
-
void release() {
-
// Only delete the lock if it's still the current one
-
// (it might have been replaced by a newer lock)
-
if (_locks[name] == next) {
-
_locks.remove(name);
-
}
-
-
// Complete the release, allowing the next operation to proceed
-
if (!releaseCompleter.isCompleted) {
-
releaseCompleter.complete();
-
}
-
}
-
-
completer.complete(release);
-
});
-
-
return completer.future;
-
}
-
-
/// Executes a function while holding a named lock.
-
///
-
/// This is a local (in-memory) lock implementation that prevents concurrent
-
/// execution of the same operation within a single isolate/process.
-
///
-
/// The lock is automatically released when the function completes or throws an error.
-
///
-
/// Example:
-
/// ```dart
-
/// final result = await requestLocalLock('my-operation', () async {
-
/// // Only one execution at a time for 'my-operation'
-
/// return await performCriticalOperation();
-
/// });
-
/// ```
-
///
-
/// Use cases:
-
/// - Token refresh (prevent multiple simultaneous refresh requests)
-
/// - Database transactions
-
/// - File operations
-
/// - Any operation that must not run concurrently with itself
-
///
-
/// Note: This is an in-memory lock. It does not work across:
-
/// - Multiple isolates
-
/// - Multiple processes
-
/// - Multiple app instances
-
///
-
/// For cross-process locking, implement a platform-specific RuntimeLock.
-
Future<T> requestLocalLock<T>(String name, FutureOr<T> Function() fn) async {
-
// Acquire the lock and get the release function
-
final release = await _acquireLocalLock(name);
-
-
try {
-
// Execute the function while holding the lock
-
return await fn();
-
} finally {
-
// Always release the lock, even if the function throws
-
release();
-
}
-
}
-
-
/// Convenience getter that returns the requestLocalLock function as a RuntimeLock.
-
///
-
/// This can be used as the default implementation for RuntimeImplementation.requestLock.
-
RuntimeLock get requestLocalLockImpl => requestLocalLock;
-530
packages/atproto_oauth_flutter/pubspec.lock
···
-
# Generated by pub
-
# See https://dart.dev/tools/pub/glossary#lockfile
-
packages:
-
async:
-
dependency: transitive
-
description:
-
name: async
-
sha256: d2872f9c19731c2e5f10444b14686eb7cc85c76274bd6c16e1816bff9a3bab63
-
url: "https://pub.dev"
-
source: hosted
-
version: "2.12.0"
-
boolean_selector:
-
dependency: transitive
-
description:
-
name: boolean_selector
-
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
-
url: "https://pub.dev"
-
source: hosted
-
version: "2.1.2"
-
characters:
-
dependency: transitive
-
description:
-
name: characters
-
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
-
url: "https://pub.dev"
-
source: hosted
-
version: "1.4.0"
-
clock:
-
dependency: transitive
-
description:
-
name: clock
-
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
-
url: "https://pub.dev"
-
source: hosted
-
version: "1.1.2"
-
collection:
-
dependency: "direct main"
-
description:
-
name: collection
-
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
-
url: "https://pub.dev"
-
source: hosted
-
version: "1.19.1"
-
convert:
-
dependency: "direct main"
-
description:
-
name: convert
-
sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68
-
url: "https://pub.dev"
-
source: hosted
-
version: "3.1.2"
-
crypto:
-
dependency: "direct main"
-
description:
-
name: crypto
-
sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855"
-
url: "https://pub.dev"
-
source: hosted
-
version: "3.0.6"
-
desktop_webview_window:
-
dependency: transitive
-
description:
-
name: desktop_webview_window
-
sha256: "57cf20d81689d5cbb1adfd0017e96b669398a669d927906073b0e42fc64111c0"
-
url: "https://pub.dev"
-
source: hosted
-
version: "0.2.3"
-
dio:
-
dependency: "direct main"
-
description:
-
name: dio
-
sha256: d90ee57923d1828ac14e492ca49440f65477f4bb1263575900be731a3dac66a9
-
url: "https://pub.dev"
-
source: hosted
-
version: "5.9.0"
-
dio_web_adapter:
-
dependency: transitive
-
description:
-
name: dio_web_adapter
-
sha256: "7586e476d70caecaf1686d21eee7247ea43ef5c345eab9e0cc3583ff13378d78"
-
url: "https://pub.dev"
-
source: hosted
-
version: "2.1.1"
-
fake_async:
-
dependency: transitive
-
description:
-
name: fake_async
-
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
-
url: "https://pub.dev"
-
source: hosted
-
version: "1.3.3"
-
ffi:
-
dependency: transitive
-
description:
-
name: ffi
-
sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418"
-
url: "https://pub.dev"
-
source: hosted
-
version: "2.1.4"
-
flutter:
-
dependency: "direct main"
-
description: flutter
-
source: sdk
-
version: "0.0.0"
-
flutter_lints:
-
dependency: "direct dev"
-
description:
-
name: flutter_lints
-
sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1"
-
url: "https://pub.dev"
-
source: hosted
-
version: "5.0.0"
-
flutter_secure_storage:
-
dependency: "direct main"
-
description:
-
name: flutter_secure_storage
-
sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea"
-
url: "https://pub.dev"
-
source: hosted
-
version: "9.2.4"
-
flutter_secure_storage_linux:
-
dependency: transitive
-
description:
-
name: flutter_secure_storage_linux
-
sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688
-
url: "https://pub.dev"
-
source: hosted
-
version: "1.2.3"
-
flutter_secure_storage_macos:
-
dependency: transitive
-
description:
-
name: flutter_secure_storage_macos
-
sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247"
-
url: "https://pub.dev"
-
source: hosted
-
version: "3.1.3"
-
flutter_secure_storage_platform_interface:
-
dependency: transitive
-
description:
-
name: flutter_secure_storage_platform_interface
-
sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8
-
url: "https://pub.dev"
-
source: hosted
-
version: "1.1.2"
-
flutter_secure_storage_web:
-
dependency: transitive
-
description:
-
name: flutter_secure_storage_web
-
sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9
-
url: "https://pub.dev"
-
source: hosted
-
version: "1.2.1"
-
flutter_secure_storage_windows:
-
dependency: transitive
-
description:
-
name: flutter_secure_storage_windows
-
sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709
-
url: "https://pub.dev"
-
source: hosted
-
version: "3.1.2"
-
flutter_test:
-
dependency: "direct dev"
-
description: flutter
-
source: sdk
-
version: "0.0.0"
-
flutter_web_auth_2:
-
dependency: "direct main"
-
description:
-
name: flutter_web_auth_2
-
sha256: "3c14babeaa066c371f3a743f204dd0d348b7d42ffa6fae7a9847a521aff33696"
-
url: "https://pub.dev"
-
source: hosted
-
version: "4.1.0"
-
flutter_web_auth_2_platform_interface:
-
dependency: transitive
-
description:
-
name: flutter_web_auth_2_platform_interface
-
sha256: c63a472c8070998e4e422f6b34a17070e60782ac442107c70000dd1bed645f4d
-
url: "https://pub.dev"
-
source: hosted
-
version: "4.1.0"
-
flutter_web_plugins:
-
dependency: transitive
-
description: flutter
-
source: sdk
-
version: "0.0.0"
-
http:
-
dependency: "direct main"
-
description:
-
name: http
-
sha256: bb2ce4590bc2667c96f318d68cac1b5a7987ec819351d32b1c987239a815e007
-
url: "https://pub.dev"
-
source: hosted
-
version: "1.5.0"
-
http_parser:
-
dependency: transitive
-
description:
-
name: http_parser
-
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
-
url: "https://pub.dev"
-
source: hosted
-
version: "4.1.2"
-
js:
-
dependency: transitive
-
description:
-
name: js
-
sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3
-
url: "https://pub.dev"
-
source: hosted
-
version: "0.6.7"
-
leak_tracker:
-
dependency: transitive
-
description:
-
name: leak_tracker
-
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
-
url: "https://pub.dev"
-
source: hosted
-
version: "11.0.2"
-
leak_tracker_flutter_testing:
-
dependency: transitive
-
description:
-
name: leak_tracker_flutter_testing
-
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
-
url: "https://pub.dev"
-
source: hosted
-
version: "3.0.10"
-
leak_tracker_testing:
-
dependency: transitive
-
description:
-
name: leak_tracker_testing
-
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
-
url: "https://pub.dev"
-
source: hosted
-
version: "3.0.2"
-
lints:
-
dependency: transitive
-
description:
-
name: lints
-
sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7
-
url: "https://pub.dev"
-
source: hosted
-
version: "5.1.1"
-
matcher:
-
dependency: transitive
-
description:
-
name: matcher
-
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
-
url: "https://pub.dev"
-
source: hosted
-
version: "0.12.17"
-
material_color_utilities:
-
dependency: transitive
-
description:
-
name: material_color_utilities
-
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
-
url: "https://pub.dev"
-
source: hosted
-
version: "0.11.1"
-
meta:
-
dependency: transitive
-
description:
-
name: meta
-
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
-
url: "https://pub.dev"
-
source: hosted
-
version: "1.16.0"
-
mime:
-
dependency: transitive
-
description:
-
name: mime
-
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
-
url: "https://pub.dev"
-
source: hosted
-
version: "2.0.0"
-
path:
-
dependency: transitive
-
description:
-
name: path
-
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
-
url: "https://pub.dev"
-
source: hosted
-
version: "1.9.1"
-
path_provider:
-
dependency: transitive
-
description:
-
name: path_provider
-
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
-
url: "https://pub.dev"
-
source: hosted
-
version: "2.1.5"
-
path_provider_android:
-
dependency: transitive
-
description:
-
name: path_provider_android
-
sha256: "3b4c1fc3aa55ddc9cd4aa6759984330d5c8e66aa7702a6223c61540dc6380c37"
-
url: "https://pub.dev"
-
source: hosted
-
version: "2.2.19"
-
path_provider_foundation:
-
dependency: transitive
-
description:
-
name: path_provider_foundation
-
sha256: "16eef174aacb07e09c351502740fa6254c165757638eba1e9116b0a781201bbd"
-
url: "https://pub.dev"
-
source: hosted
-
version: "2.4.2"
-
path_provider_linux:
-
dependency: transitive
-
description:
-
name: path_provider_linux
-
sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279
-
url: "https://pub.dev"
-
source: hosted
-
version: "2.2.1"
-
path_provider_platform_interface:
-
dependency: transitive
-
description:
-
name: path_provider_platform_interface
-
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
-
url: "https://pub.dev"
-
source: hosted
-
version: "2.1.2"
-
path_provider_windows:
-
dependency: transitive
-
description:
-
name: path_provider_windows
-
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
-
url: "https://pub.dev"
-
source: hosted
-
version: "2.3.0"
-
platform:
-
dependency: transitive
-
description:
-
name: platform
-
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
-
url: "https://pub.dev"
-
source: hosted
-
version: "3.1.6"
-
plugin_platform_interface:
-
dependency: transitive
-
description:
-
name: plugin_platform_interface
-
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
-
url: "https://pub.dev"
-
source: hosted
-
version: "2.1.8"
-
pointycastle:
-
dependency: "direct main"
-
description:
-
name: pointycastle
-
sha256: "4be0097fcf3fd3e8449e53730c631200ebc7b88016acecab2b0da2f0149222fe"
-
url: "https://pub.dev"
-
source: hosted
-
version: "3.9.1"
-
sky_engine:
-
dependency: transitive
-
description: flutter
-
source: sdk
-
version: "0.0.0"
-
source_span:
-
dependency: transitive
-
description:
-
name: source_span
-
sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c"
-
url: "https://pub.dev"
-
source: hosted
-
version: "1.10.1"
-
stack_trace:
-
dependency: transitive
-
description:
-
name: stack_trace
-
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
-
url: "https://pub.dev"
-
source: hosted
-
version: "1.12.1"
-
stream_channel:
-
dependency: transitive
-
description:
-
name: stream_channel
-
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
-
url: "https://pub.dev"
-
source: hosted
-
version: "2.1.4"
-
string_scanner:
-
dependency: transitive
-
description:
-
name: string_scanner
-
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
-
url: "https://pub.dev"
-
source: hosted
-
version: "1.4.1"
-
term_glyph:
-
dependency: transitive
-
description:
-
name: term_glyph
-
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
-
url: "https://pub.dev"
-
source: hosted
-
version: "1.2.2"
-
test_api:
-
dependency: transitive
-
description:
-
name: test_api
-
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
-
url: "https://pub.dev"
-
source: hosted
-
version: "0.7.6"
-
typed_data:
-
dependency: transitive
-
description:
-
name: typed_data
-
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
-
url: "https://pub.dev"
-
source: hosted
-
version: "1.4.0"
-
url_launcher:
-
dependency: transitive
-
description:
-
name: url_launcher
-
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
-
url: "https://pub.dev"
-
source: hosted
-
version: "6.3.2"
-
url_launcher_android:
-
dependency: transitive
-
description:
-
name: url_launcher_android
-
sha256: "81777b08c498a292d93ff2feead633174c386291e35612f8da438d6e92c4447e"
-
url: "https://pub.dev"
-
source: hosted
-
version: "6.3.20"
-
url_launcher_ios:
-
dependency: transitive
-
description:
-
name: url_launcher_ios
-
sha256: d80b3f567a617cb923546034cc94bfe44eb15f989fe670b37f26abdb9d939cb7
-
url: "https://pub.dev"
-
source: hosted
-
version: "6.3.4"
-
url_launcher_linux:
-
dependency: transitive
-
description:
-
name: url_launcher_linux
-
sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935"
-
url: "https://pub.dev"
-
source: hosted
-
version: "3.2.1"
-
url_launcher_macos:
-
dependency: transitive
-
description:
-
name: url_launcher_macos
-
sha256: c043a77d6600ac9c38300567f33ef12b0ef4f4783a2c1f00231d2b1941fea13f
-
url: "https://pub.dev"
-
source: hosted
-
version: "3.2.3"
-
url_launcher_platform_interface:
-
dependency: transitive
-
description:
-
name: url_launcher_platform_interface
-
sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
-
url: "https://pub.dev"
-
source: hosted
-
version: "2.3.2"
-
url_launcher_web:
-
dependency: transitive
-
description:
-
name: url_launcher_web
-
sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2"
-
url: "https://pub.dev"
-
source: hosted
-
version: "2.4.1"
-
url_launcher_windows:
-
dependency: transitive
-
description:
-
name: url_launcher_windows
-
sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77"
-
url: "https://pub.dev"
-
source: hosted
-
version: "3.1.4"
-
vector_math:
-
dependency: transitive
-
description:
-
name: vector_math
-
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
-
url: "https://pub.dev"
-
source: hosted
-
version: "2.2.0"
-
vm_service:
-
dependency: transitive
-
description:
-
name: vm_service
-
sha256: "0968250880a6c5fe7edc067ed0a13d4bae1577fe2771dcf3010d52c4a9d3ca14"
-
url: "https://pub.dev"
-
source: hosted
-
version: "14.3.1"
-
web:
-
dependency: transitive
-
description:
-
name: web
-
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
-
url: "https://pub.dev"
-
source: hosted
-
version: "1.1.1"
-
win32:
-
dependency: transitive
-
description:
-
name: win32
-
sha256: "329edf97fdd893e0f1e3b9e88d6a0e627128cc17cc316a8d67fda8f1451178ba"
-
url: "https://pub.dev"
-
source: hosted
-
version: "5.13.0"
-
window_to_front:
-
dependency: transitive
-
description:
-
name: window_to_front
-
sha256: "7aef379752b7190c10479e12b5fd7c0b9d92adc96817d9e96c59937929512aee"
-
url: "https://pub.dev"
-
source: hosted
-
version: "0.0.3"
-
xdg_directories:
-
dependency: transitive
-
description:
-
name: xdg_directories
-
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
-
url: "https://pub.dev"
-
source: hosted
-
version: "1.1.0"
-
sdks:
-
dart: ">=3.8.0-0 <4.0.0"
-
flutter: ">=3.29.0"
-24
packages/atproto_oauth_flutter/pubspec.yaml
···
-
name: atproto_oauth_flutter
-
description: Official AT Protocol OAuth client for Flutter - 1:1 port of @atproto/oauth-client
-
version: 0.1.0
-
publish_to: none
-
-
environment:
-
sdk: ^3.7.2
-
-
dependencies:
-
flutter:
-
sdk: flutter
-
dio: ^5.9.0
-
flutter_secure_storage: ^9.2.2
-
flutter_web_auth_2: ^4.1.0
-
crypto: ^3.0.3
-
pointycastle: ^3.9.1
-
convert: ^3.1.1
-
collection: ^1.18.0
-
http: ^1.2.0
-
-
dev_dependencies:
-
flutter_test:
-
sdk: flutter
-
flutter_lints: ^5.0.0
-245
packages/atproto_oauth_flutter/test/identity_resolver_test.dart
···
-
/// Unit tests for the identity resolution layer.
-
///
-
/// Note: These are basic validation tests. Real integration tests would
-
/// require network calls to live services.
-
-
import 'package:flutter_test/flutter_test.dart';
-
import 'package:atproto_oauth_flutter/src/identity/identity.dart';
-
-
void main() {
-
group('DID Validation', () {
-
test('isDidPlc validates did:plc correctly', () {
-
// did:plc must be exactly 32 chars total (8 prefix + 24 base32 [a-z2-7])
-
expect(isDidPlc('did:plc:z72i7hdynmk6r22z27h6abc2'), isTrue);
-
expect(isDidPlc('did:plc:2222222222222222222222ab'), isTrue);
-
expect(isDidPlc('did:plc:abcdefgabcdefgabcdefgabc'), isTrue);
-
-
// Wrong length
-
expect(isDidPlc('did:plc:short'), isFalse);
-
expect(isDidPlc('did:plc:toolonggggggggggggggggggggg'), isFalse);
-
-
// Wrong prefix
-
expect(isDidPlc('did:web:example.com'), isFalse);
-
-
// Invalid characters (not base32)
-
expect(isDidPlc('did:plc:0000000000000000000000'), isFalse); // has 0
-
expect(isDidPlc('did:plc:1111111111111111111111'), isFalse); // has 1
-
});
-
-
test('isDidWeb validates did:web correctly', () {
-
expect(isDidWeb('did:web:example.com'), isTrue);
-
expect(isDidWeb('did:web:example.com:user:alice'), isTrue);
-
expect(isDidWeb('did:web:localhost%3A3000'), isTrue);
-
-
// Wrong prefix
-
expect(isDidWeb('did:plc:abc123xyz789abc123xyz789'), isFalse);
-
-
// Can't start with colon after prefix
-
expect(isDidWeb('did:web::example.com'), isFalse);
-
});
-
-
test('isDid validates general DIDs', () {
-
expect(isDid('did:plc:abc123xyz789abc123xyz789'), isTrue);
-
expect(isDid('did:web:example.com'), isTrue);
-
expect(
-
isDid('did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK'),
-
isTrue,
-
);
-
-
// Invalid
-
expect(isDid('not-a-did'), isFalse);
-
expect(isDid('did:'), isFalse);
-
expect(isDid('did:method'), isFalse);
-
expect(isDid(''), isFalse);
-
});
-
-
test('extractDidMethod extracts method name', () {
-
expect(extractDidMethod('did:plc:abc123'), equals('plc'));
-
expect(extractDidMethod('did:web:example.com'), equals('web'));
-
expect(extractDidMethod('did:key:z6Mk...'), equals('key'));
-
});
-
-
test('didWebToUrl converts did:web to URL', () {
-
final url1 = didWebToUrl('did:web:example.com');
-
expect(url1.toString(), equals('https://example.com'));
-
-
final url2 = didWebToUrl('did:web:example.com:user:alice');
-
expect(url2.toString(), equals('https://example.com/user/alice'));
-
-
final url3 = didWebToUrl('did:web:localhost%3A3000');
-
expect(url3.toString(), equals('http://localhost:3000'));
-
});
-
-
test('urlToDidWeb converts URL to did:web', () {
-
final did1 = urlToDidWeb(Uri.parse('https://example.com'));
-
expect(did1, equals('did:web:example.com'));
-
-
final did2 = urlToDidWeb(Uri.parse('https://example.com/user/alice'));
-
expect(did2, equals('did:web:example.com:user:alice'));
-
});
-
});
-
-
group('Handle Validation', () {
-
test('isValidHandle validates handles', () {
-
expect(isValidHandle('alice.example.com'), isTrue);
-
expect(isValidHandle('user.bsky.social'), isTrue);
-
expect(isValidHandle('sub.domain.example.com'), isTrue);
-
expect(isValidHandle('a.b'), isTrue);
-
-
// Invalid
-
expect(isValidHandle(''), isFalse);
-
expect(isValidHandle('no-tld'), isFalse);
-
expect(isValidHandle('.starts-with-dot.com'), isFalse);
-
expect(isValidHandle('ends-with-dot.com.'), isFalse);
-
expect(isValidHandle('has..double-dot.com'), isFalse);
-
expect(isValidHandle('has spaces.com'), isFalse);
-
-
// Too long (254+ chars)
-
final longHandle = '${'a' * 250}.com';
-
expect(isValidHandle(longHandle), isFalse);
-
});
-
-
test('normalizeHandle converts to lowercase', () {
-
expect(normalizeHandle('Alice.Example.Com'), equals('alice.example.com'));
-
expect(normalizeHandle('USER.BSKY.SOCIAL'), equals('user.bsky.social'));
-
});
-
-
test('asNormalizedHandle validates and normalizes', () {
-
expect(
-
asNormalizedHandle('Alice.Example.Com'),
-
equals('alice.example.com'),
-
);
-
expect(asNormalizedHandle('invalid'), isNull);
-
expect(asNormalizedHandle(''), isNull);
-
});
-
});
-
-
group('DID Document', () {
-
test('DidDocument parses from JSON', () {
-
final json = {
-
'id': 'did:plc:abc123xyz789abc123xyz789',
-
'alsoKnownAs': ['at://alice.bsky.social'],
-
'service': [
-
{
-
'id': '#atproto_pds',
-
'type': 'AtprotoPersonalDataServer',
-
'serviceEndpoint': 'https://pds.example.com',
-
},
-
],
-
};
-
-
final doc = DidDocument.fromJson(json);
-
-
expect(doc.id, equals('did:plc:abc123xyz789abc123xyz789'));
-
expect(doc.alsoKnownAs, contains('at://alice.bsky.social'));
-
expect(doc.service?.length, equals(1));
-
expect(doc.service?[0].type, equals('AtprotoPersonalDataServer'));
-
});
-
-
test('DidDocument extracts PDS URL', () {
-
final doc = DidDocument(
-
id: 'did:plc:test',
-
service: [
-
DidService(
-
id: '#atproto_pds',
-
type: 'AtprotoPersonalDataServer',
-
serviceEndpoint: 'https://pds.example.com',
-
),
-
],
-
);
-
-
expect(doc.extractPdsUrl(), equals('https://pds.example.com'));
-
});
-
-
test('DidDocument extracts handle', () {
-
final doc = DidDocument(
-
id: 'did:plc:test',
-
alsoKnownAs: ['at://alice.bsky.social', 'https://example.com'],
-
);
-
-
expect(doc.extractAtprotoHandle(), equals('alice.bsky.social'));
-
expect(doc.extractNormalizedHandle(), equals('alice.bsky.social'));
-
});
-
-
test('DidDocument returns null for missing PDS', () {
-
final doc = DidDocument(id: 'did:plc:test');
-
expect(doc.extractPdsUrl(), isNull);
-
});
-
-
test('DidDocument returns null for missing handle', () {
-
final doc = DidDocument(id: 'did:plc:test');
-
expect(doc.extractAtprotoHandle(), isNull);
-
expect(doc.extractNormalizedHandle(), isNull);
-
});
-
});
-
-
group('Cache', () {
-
test('InMemoryDidCache stores and retrieves', () async {
-
final cache = InMemoryDidCache(ttl: Duration(seconds: 1));
-
final doc = DidDocument(id: 'did:plc:test');
-
-
await cache.set('did:plc:test', doc);
-
final retrieved = await cache.get('did:plc:test');
-
-
expect(retrieved?.id, equals('did:plc:test'));
-
});
-
-
test('InMemoryDidCache expires entries', () async {
-
final cache = InMemoryDidCache(ttl: Duration(milliseconds: 100));
-
final doc = DidDocument(id: 'did:plc:test');
-
-
await cache.set('did:plc:test', doc);
-
-
// Should exist immediately
-
expect(await cache.get('did:plc:test'), isNotNull);
-
-
// Wait for expiration
-
await Future.delayed(Duration(milliseconds: 150));
-
-
// Should be expired
-
expect(await cache.get('did:plc:test'), isNull);
-
});
-
-
test('InMemoryHandleCache stores and retrieves', () async {
-
final cache = InMemoryHandleCache(ttl: Duration(seconds: 1));
-
-
await cache.set('alice.bsky.social', 'did:plc:test');
-
final retrieved = await cache.get('alice.bsky.social');
-
-
expect(retrieved, equals('did:plc:test'));
-
});
-
-
test('Cache clears all entries', () async {
-
final cache = InMemoryDidCache();
-
final doc = DidDocument(id: 'did:plc:test');
-
-
await cache.set('did:plc:test', doc);
-
expect(await cache.get('did:plc:test'), isNotNull);
-
-
await cache.clear();
-
expect(await cache.get('did:plc:test'), isNull);
-
});
-
});
-
-
group('Error Types', () {
-
test('IdentityResolverError has message', () {
-
final error = IdentityResolverError('Test error');
-
expect(error.message, equals('Test error'));
-
expect(error.toString(), contains('Test error'));
-
});
-
-
test('InvalidDidError includes DID', () {
-
final error = InvalidDidError('not:valid', 'Invalid format');
-
expect(error.did, equals('not:valid'));
-
expect(error.toString(), contains('not:valid'));
-
expect(error.toString(), contains('Invalid format'));
-
});
-
-
test('InvalidHandleError includes handle', () {
-
final error = InvalidHandleError('invalid', 'Invalid format');
-
expect(error.handle, equals('invalid'));
-
expect(error.toString(), contains('invalid'));
-
expect(error.toString(), contains('Invalid format'));
-
});
-
});
-
}
+10
ios/Runner/Runner.entitlements
···
+
<?xml version="1.0" encoding="UTF-8"?>
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+
<plist version="1.0">
+
<dict>
+
<key>com.apple.developer.associated-domains</key>
+
<array>
+
<string>applinks:coves.social</string>
+
</array>
+
</dict>
+
</plist>
+11 -4
test/widgets/feed_screen_test.dart
···
voteService: VoteService(
sessionGetter: () async => null,
didGetter: () => null,
-
pdsUrlGetter: () => null,
),
authProvider: FakeAuthProvider(),
);
···
community: CommunityRef(
did: 'did:plc:community',
name: 'test-community',
+
handle: 'test-community.community.coves.social',
),
createdAt: DateTime.now(),
indexedAt: DateTime.now(),
···
await tester.pumpWidget(createTestWidget());
-
expect(find.text('c/test-community'), findsOneWidget);
+
// Check for community handle parts (displayed as !test-community@coves.social)
+
expect(find.textContaining('!test-community'), findsOneWidget);
expect(find.text('@test.user'), findsOneWidget);
});
···
// Verify post card exists (which contains Semantics wrapper)
expect(find.text('Accessible Post'), findsOneWidget);
-
expect(find.text('c/test-community'), findsOneWidget);
+
// Check for community handle parts (displayed as !test-community@coves.social)
+
expect(find.textContaining('!test-community'), findsOneWidget);
+
expect(find.textContaining('@coves.social'), findsOneWidget);
});
testWidgets('should properly dispose scroll controller', (tester) async {
···
handle: 'test.user',
displayName: 'Test User',
),
-
community: CommunityRef(did: 'did:plc:community', name: 'test-community'),
+
community: CommunityRef(
+
did: 'did:plc:community',
+
name: 'test-community',
+
handle: 'test-community.community.coves.social',
+
),
createdAt: DateTime.parse('2025-01-01T12:00:00Z'),
indexedAt: DateTime.parse('2025-01-01T12:00:00Z'),
text: 'Test body',
-1
lib/widgets/icons/bluesky_icons.dart
···
/// Bluesky-style navigation icons using SVG assets
/// These icons match the design from Bluesky's social-app
class BlueSkyIcon extends StatelessWidget {
-
const BlueSkyIcon({
required this.iconName,
this.size = 28,
+1 -3
lib/widgets/post_action_bar.dart
···
return Container(
decoration: const BoxDecoration(
color: AppColors.background,
-
border: Border(
-
top: BorderSide(color: AppColors.backgroundSecondary),
-
),
+
border: Border(top: BorderSide(color: AppColors.backgroundSecondary)),
),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: SafeArea(
+15 -18
lib/widgets/post_card.dart
···
),
)
else
-
// Title when navigation is disabled
-
if (post.post.title != null) ...[
-
Text(
-
post.post.title!,
-
style: TextStyle(
-
color: AppColors.textPrimary,
-
fontSize: titleFontSize,
-
fontWeight: titleFontWeight,
-
height: 1.3,
-
),
+
// Title when navigation is disabled
+
if (post.post.title != null) ...[
+
Text(
+
post.post.title!,
+
style: TextStyle(
+
color: AppColors.textPrimary,
+
fontSize: titleFontSize,
+
fontWeight: titleFontWeight,
+
height: 1.3,
),
-
if (post.post.embed?.external != null ||
-
post.post.text.isNotEmpty)
-
const SizedBox(height: 8),
-
],
+
),
+
if (post.post.embed?.external != null ||
+
post.post.text.isNotEmpty)
+
const SizedBox(height: 8),
+
],
// Embed (handles its own taps - not wrapped in InkWell)
if (post.post.embed?.external != null) ...[
···
// For non-video embeds (images, link previews), make them tappable
// to navigate to post detail
if (widget.onImageTap != null) {
-
return GestureDetector(
-
onTap: widget.onImageTap,
-
child: thumbnailWidget,
-
);
+
return GestureDetector(onTap: widget.onImageTap, child: thumbnailWidget);
}
// No tap handler provided, just return the thumbnail
+6 -8
lib/widgets/post_card_actions.dart
···
Icon(
Icons.chat_bubble_outline,
size: 20,
-
color:
-
AppColors.textPrimary.withValues(
-
alpha: 0.6,
-
),
+
color: AppColors.textPrimary.withValues(
+
alpha: 0.6,
+
),
),
const SizedBox(width: 5),
Text(
DateTimeUtils.formatCount(count),
style: TextStyle(
-
color:
-
AppColors.textPrimary.withValues(
-
alpha: 0.6,
-
),
+
color: AppColors.textPrimary.withValues(
+
alpha: 0.6,
+
),
fontSize: 13,
),
),
+2 -19
test/services/coves_auth_service_redaction_test.dart
···
import 'package:coves_flutter/models/coves_session.dart';
import 'package:coves_flutter/services/coves_auth_service.dart';
-
import 'package:dio/dio.dart';
-
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:flutter_test/flutter_test.dart';
-
import 'package:mockito/annotations.dart';
-
import 'package:mockito/mockito.dart';
-
-
import 'coves_auth_service_test.mocks.dart';
/// Tests for sensitive data redaction in CovesAuthService
///
/// Verifies that sensitive parameters (tokens) are properly redacted
/// from debug logs while preserving useful debugging information.
-
@GenerateMocks([Dio, FlutterSecureStorage])
void main() {
-
late CovesAuthService service;
-
late MockDio mockDio;
-
late MockFlutterSecureStorage mockStorage;
-
setUp(() {
-
mockDio = MockDio();
-
mockStorage = MockFlutterSecureStorage();
-
-
// Create a test instance
-
service = CovesAuthService.createTestInstance(
-
dio: mockDio,
-
storage: mockStorage,
-
);
+
// Reset singleton state before each test
+
CovesAuthService.resetInstance();
});
tearDown(() {
+1
test/services/coves_auth_service_singleton_test.dart
···
test('resetInstance() should clear the singleton', () {
// Arrange
final instance1 = CovesAuthService(dio: mockDio, storage: mockStorage);
+
expect(instance1, isNotNull); // Verify initial singleton exists
// Act
CovesAuthService.resetInstance();
-1
test/services/coves_auth_service_validation_test.dart
···
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/annotations.dart';
-
import 'package:mockito/mockito.dart';
import 'coves_auth_service_test.mocks.dart';
+9 -2
lib/models/comment.dart
···
}
class CommentViewerState {
-
CommentViewerState({this.vote});
+
CommentViewerState({this.vote, this.voteUri});
factory CommentViewerState.fromJson(Map<String, dynamic> json) {
-
return CommentViewerState(vote: json['vote'] as String?);
+
return CommentViewerState(
+
vote: json['vote'] as String?,
+
voteUri: json['voteUri'] as String?,
+
);
}
+
/// Vote direction: "up", "down", or null if not voted
final String? vote;
+
+
/// AT-URI of the vote record (if backend provides it)
+
final String? voteUri;
}
+41
lib/models/post.dart
···
final FeedReason? reason;
}
+
class ViewerState {
+
ViewerState({
+
this.vote,
+
this.voteUri,
+
this.saved = false,
+
this.savedUri,
+
this.tags,
+
});
+
+
factory ViewerState.fromJson(Map<String, dynamic> json) {
+
return ViewerState(
+
vote: json['vote'] as String?,
+
voteUri: json['voteUri'] as String?,
+
saved: json['saved'] as bool? ?? false,
+
savedUri: json['savedUri'] as String?,
+
tags: (json['tags'] as List<dynamic>?)?.cast<String>(),
+
);
+
}
+
+
/// Vote direction: "up", "down", or null if not voted
+
final String? vote;
+
+
/// AT-URI of the vote record
+
final String? voteUri;
+
+
/// Whether the post is saved/bookmarked
+
final bool saved;
+
+
/// AT-URI of the saved record
+
final String? savedUri;
+
+
/// User-applied tags
+
final List<String>? tags;
+
}
+
class PostView {
PostView({
required this.uri,
···
required this.stats,
this.embed,
this.facets,
+
this.viewer,
});
factory PostView.fromJson(Map<String, dynamic> json) {
···
.map((f) => PostFacet.fromJson(f as Map<String, dynamic>))
.toList()
: null,
+
viewer:
+
json['viewer'] != null
+
? ViewerState.fromJson(json['viewer'] as Map<String, dynamic>)
+
: null,
);
}
final String uri;
···
final PostStats stats;
final PostEmbed? embed;
final List<PostFacet>? facets;
+
final ViewerState? viewer;
}
class AuthorView {
+20 -51
lib/providers/vote_provider.dart
···
import 'package:flutter/foundation.dart';
import '../services/api_exceptions.dart';
-
import '../services/vote_service.dart' show VoteService, VoteInfo;
+
import '../services/vote_service.dart' show VoteService;
import 'auth_provider.dart';
/// Vote Provider
···
_pendingRequests[postUri] = true;
try {
-
// Make API call - pass existing vote info to avoid O(n) PDS lookup
+
// Make API call
final response = await _voteService.createVote(
postUri: postUri,
postCid: postCid,
direction: direction,
-
existingVoteRkey: currentState?.rkey,
-
existingVoteDirection: currentState?.direction,
);
// Update with server response
···
String? voteUri,
}) {
if (voteDirection != null) {
-
// Extract rkey from vote URI if available
-
// URI format: at://did:plc:xyz/social.coves.feed.vote/3kby...
-
String? rkey;
-
if (voteUri != null) {
-
final parts = voteUri.split('/');
-
if (parts.isNotEmpty) {
-
rkey = parts.last;
-
}
-
}
-
_votes[postUri] = VoteState(
direction: voteDirection,
uri: voteUri,
-
rkey: rkey,
+
rkey: VoteState.extractRkeyFromUri(voteUri),
deleted: false,
);
} else {
_votes.remove(postUri);
}
-
// Don't notify listeners - this is just initial state
-
}
-
-
/// Load initial vote states from a map of votes
-
///
-
/// This is used to bulk-load vote state after querying the user's PDS.
-
/// Typically called after loading feed posts to fill in which posts
-
/// the user has voted on.
-
///
-
/// IMPORTANT: This clears score adjustments since the server score
-
/// already reflects the loaded votes. If we kept stale adjustments,
-
/// we'd double-count votes (server score + our adjustment).
-
///
-
/// Parameters:
-
/// - [votes]: Map of post URI -> vote info from VoteService.getUserVotes()
-
void loadInitialVotes(Map<String, VoteInfo> votes) {
-
for (final entry in votes.entries) {
-
final postUri = entry.key;
-
final voteInfo = entry.value;
-
-
_votes[postUri] = VoteState(
-
direction: voteInfo.direction,
-
uri: voteInfo.voteUri,
-
rkey: voteInfo.rkey,
-
deleted: false,
-
);
-
-
// Clear any stale score adjustments for this post
-
// The server score already includes this vote
-
_scoreAdjustments.remove(postUri);
-
}
-
if (kDebugMode) {
-
debugPrint('๐Ÿ“Š Initialized ${votes.length} vote states');
-
}
+
// IMPORTANT: Clear any stale score adjustment for this post.
+
// When we receive fresh data from the server (via feed/comments refresh),
+
// the server's score already reflects the actual vote state. Any local
+
// delta from a previous optimistic update is now stale and would cause
+
// double-counting (e.g., server score already includes +1, plus our +1).
+
_scoreAdjustments.remove(postUri);
-
// Notify once after loading all votes
-
notifyListeners();
+
// Don't notify listeners - this is just initial state
}
/// Clear all vote state (e.g., on sign out)
···
/// Whether the vote has been deleted
final bool deleted;
+
+
/// Extract rkey (record key) from an AT-URI
+
///
+
/// AT-URI format: at://did:plc:xyz/social.coves.feed.vote/3kby...
+
/// Returns the last segment (rkey) or null if URI is null/invalid.
+
static String? extractRkeyFromUri(String? uri) {
+
if (uri == null) return null;
+
final parts = uri.split('/');
+
return parts.isNotEmpty ? parts.last : null;
+
}
}
+229 -1
test/providers/feed_provider_test.dart
···
import 'package:coves_flutter/models/post.dart';
import 'package:coves_flutter/providers/auth_provider.dart';
import 'package:coves_flutter/providers/feed_provider.dart';
+
import 'package:coves_flutter/providers/vote_provider.dart';
import 'package:coves_flutter/services/coves_api_service.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/annotations.dart';
···
import 'feed_provider_test.mocks.dart';
// Generate mocks
-
@GenerateMocks([AuthProvider, CovesApiService])
+
@GenerateMocks([AuthProvider, CovesApiService, VoteProvider])
void main() {
group('FeedProvider', () {
late FeedProvider feedProvider;
···
expect(feedProvider.isLoading, false);
});
});
+
+
group('Vote state initialization from viewer data', () {
+
late MockVoteProvider mockVoteProvider;
+
late FeedProvider feedProviderWithVotes;
+
+
setUp(() {
+
mockVoteProvider = MockVoteProvider();
+
feedProviderWithVotes = FeedProvider(
+
mockAuthProvider,
+
apiService: mockApiService,
+
voteProvider: mockVoteProvider,
+
);
+
});
+
+
tearDown(() {
+
feedProviderWithVotes.dispose();
+
});
+
+
test('should initialize vote state when viewer.vote is "up"', () async {
+
when(mockAuthProvider.isAuthenticated).thenReturn(true);
+
+
final mockResponse = TimelineResponse(
+
feed: [
+
_createMockPostWithViewer(
+
uri: 'at://did:plc:test/social.coves.post.record/1',
+
vote: 'up',
+
voteUri: 'at://did:plc:test/social.coves.feed.vote/vote1',
+
),
+
],
+
cursor: 'cursor',
+
);
+
+
when(
+
mockApiService.getTimeline(
+
sort: anyNamed('sort'),
+
timeframe: anyNamed('timeframe'),
+
limit: anyNamed('limit'),
+
cursor: anyNamed('cursor'),
+
),
+
).thenAnswer((_) async => mockResponse);
+
+
await feedProviderWithVotes.fetchTimeline(refresh: true);
+
+
verify(
+
mockVoteProvider.setInitialVoteState(
+
postUri: 'at://did:plc:test/social.coves.post.record/1',
+
voteDirection: 'up',
+
voteUri: 'at://did:plc:test/social.coves.feed.vote/vote1',
+
),
+
).called(1);
+
});
+
+
test('should initialize vote state when viewer.vote is "down"', () async {
+
when(mockAuthProvider.isAuthenticated).thenReturn(true);
+
+
final mockResponse = TimelineResponse(
+
feed: [
+
_createMockPostWithViewer(
+
uri: 'at://did:plc:test/social.coves.post.record/1',
+
vote: 'down',
+
voteUri: 'at://did:plc:test/social.coves.feed.vote/vote1',
+
),
+
],
+
cursor: 'cursor',
+
);
+
+
when(
+
mockApiService.getTimeline(
+
sort: anyNamed('sort'),
+
timeframe: anyNamed('timeframe'),
+
limit: anyNamed('limit'),
+
cursor: anyNamed('cursor'),
+
),
+
).thenAnswer((_) async => mockResponse);
+
+
await feedProviderWithVotes.fetchTimeline(refresh: true);
+
+
verify(
+
mockVoteProvider.setInitialVoteState(
+
postUri: 'at://did:plc:test/social.coves.post.record/1',
+
voteDirection: 'down',
+
voteUri: 'at://did:plc:test/social.coves.feed.vote/vote1',
+
),
+
).called(1);
+
});
+
+
test(
+
'should clear stale vote state when viewer.vote is null on refresh',
+
() async {
+
when(mockAuthProvider.isAuthenticated).thenReturn(true);
+
+
// Feed item with null vote (user removed vote on another device)
+
final mockResponse = TimelineResponse(
+
feed: [
+
_createMockPostWithViewer(
+
uri: 'at://did:plc:test/social.coves.post.record/1',
+
vote: null,
+
voteUri: null,
+
),
+
],
+
cursor: 'cursor',
+
);
+
+
when(
+
mockApiService.getTimeline(
+
sort: anyNamed('sort'),
+
timeframe: anyNamed('timeframe'),
+
limit: anyNamed('limit'),
+
cursor: anyNamed('cursor'),
+
),
+
).thenAnswer((_) async => mockResponse);
+
+
await feedProviderWithVotes.fetchTimeline(refresh: true);
+
+
// Should call setInitialVoteState with null to clear stale state
+
verify(
+
mockVoteProvider.setInitialVoteState(
+
postUri: 'at://did:plc:test/social.coves.post.record/1',
+
voteDirection: null,
+
voteUri: null,
+
),
+
).called(1);
+
},
+
);
+
+
test(
+
'should initialize vote state for all feed items including no viewer',
+
() async {
+
when(mockAuthProvider.isAuthenticated).thenReturn(true);
+
+
final mockResponse = TimelineResponse(
+
feed: [
+
_createMockPostWithViewer(
+
uri: 'at://did:plc:test/social.coves.post.record/1',
+
vote: 'up',
+
voteUri: 'at://did:plc:test/social.coves.feed.vote/vote1',
+
),
+
_createMockPost(), // No viewer state
+
],
+
cursor: 'cursor',
+
);
+
+
when(
+
mockApiService.getTimeline(
+
sort: anyNamed('sort'),
+
timeframe: anyNamed('timeframe'),
+
limit: anyNamed('limit'),
+
cursor: anyNamed('cursor'),
+
),
+
).thenAnswer((_) async => mockResponse);
+
+
await feedProviderWithVotes.fetchTimeline(refresh: true);
+
+
// Should be called for both posts
+
verify(
+
mockVoteProvider.setInitialVoteState(
+
postUri: anyNamed('postUri'),
+
voteDirection: anyNamed('voteDirection'),
+
voteUri: anyNamed('voteUri'),
+
),
+
).called(2);
+
},
+
);
+
+
test('should not initialize vote state when not authenticated', () async {
+
when(mockAuthProvider.isAuthenticated).thenReturn(false);
+
+
final mockResponse = TimelineResponse(
+
feed: [
+
_createMockPostWithViewer(
+
uri: 'at://did:plc:test/social.coves.post.record/1',
+
vote: 'up',
+
voteUri: 'at://did:plc:test/social.coves.feed.vote/vote1',
+
),
+
],
+
cursor: 'cursor',
+
);
+
+
when(
+
mockApiService.getDiscover(
+
sort: anyNamed('sort'),
+
timeframe: anyNamed('timeframe'),
+
limit: anyNamed('limit'),
+
cursor: anyNamed('cursor'),
+
),
+
).thenAnswer((_) async => mockResponse);
+
+
await feedProviderWithVotes.fetchDiscover(refresh: true);
+
+
// Should NOT call setInitialVoteState when not authenticated
+
verifyNever(
+
mockVoteProvider.setInitialVoteState(
+
postUri: anyNamed('postUri'),
+
voteDirection: anyNamed('voteDirection'),
+
voteUri: anyNamed('voteUri'),
+
),
+
);
+
});
+
});
});
}
···
),
);
}
+
+
// Helper function to create mock posts with viewer state
+
FeedViewPost _createMockPostWithViewer({
+
required String uri,
+
String? vote,
+
String? voteUri,
+
}) {
+
return FeedViewPost(
+
post: PostView(
+
uri: uri,
+
cid: 'test-cid',
+
rkey: 'test-rkey',
+
author: AuthorView(
+
did: 'did:plc:author',
+
handle: 'test.user',
+
displayName: 'Test User',
+
),
+
community: CommunityRef(did: 'did:plc:community', name: 'test-community'),
+
createdAt: DateTime.parse('2025-01-01T12:00:00Z'),
+
indexedAt: DateTime.parse('2025-01-01T12:00:00Z'),
+
text: 'Test body',
+
title: 'Test Post',
+
stats: PostStats(score: 42, upvotes: 50, downvotes: 8, commentCount: 5),
+
facets: [],
+
viewer: ViewerState(vote: vote, voteUri: voteUri),
+
),
+
);
+
}
+134 -28
test/providers/vote_provider_test.dart
···
postUri: anyNamed('postUri'),
postCid: anyNamed('postCid'),
direction: anyNamed('direction'),
-
existingVoteRkey: anyNamed('existingVoteRkey'),
-
existingVoteDirection: anyNamed('existingVoteDirection'),
),
).thenAnswer(
(_) async => const VoteResponse(
···
postUri: anyNamed('postUri'),
postCid: anyNamed('postCid'),
direction: anyNamed('direction'),
-
existingVoteRkey: anyNamed('existingVoteRkey'),
-
existingVoteDirection: anyNamed('existingVoteDirection'),
),
).thenAnswer((_) async => const VoteResponse(deleted: true));
···
postUri: anyNamed('postUri'),
postCid: anyNamed('postCid'),
direction: anyNamed('direction'),
-
existingVoteRkey: anyNamed('existingVoteRkey'),
-
existingVoteDirection: anyNamed('existingVoteDirection'),
),
).thenThrow(ApiException('Network error', statusCode: 500));
···
postUri: anyNamed('postUri'),
postCid: anyNamed('postCid'),
direction: anyNamed('direction'),
-
existingVoteRkey: anyNamed('existingVoteRkey'),
-
existingVoteDirection: anyNamed('existingVoteDirection'),
),
).thenThrow(NetworkException('Connection failed'));
···
postUri: anyNamed('postUri'),
postCid: anyNamed('postCid'),
direction: anyNamed('direction'),
-
existingVoteRkey: anyNamed('existingVoteRkey'),
-
existingVoteDirection: anyNamed('existingVoteDirection'),
),
).thenAnswer((_) async {
await Future.delayed(const Duration(milliseconds: 100));
···
postUri: anyNamed('postUri'),
postCid: anyNamed('postCid'),
direction: anyNamed('direction'),
-
existingVoteRkey: anyNamed('existingVoteRkey'),
-
existingVoteDirection: anyNamed('existingVoteDirection'),
),
).called(1);
});
···
postUri: anyNamed('postUri'),
postCid: anyNamed('postCid'),
direction: anyNamed('direction'),
-
existingVoteRkey: anyNamed('existingVoteRkey'),
-
existingVoteDirection: anyNamed('existingVoteDirection'),
),
).thenAnswer(
(_) async => const VoteResponse(
···
expect(voteState?.deleted, false);
});
+
test('should set initial vote state with "down" direction', () {
+
voteProvider.setInitialVoteState(
+
postUri: testPostUri,
+
voteDirection: 'down',
+
voteUri: 'at://did:plc:test/social.coves.feed.vote/456',
+
);
+
+
// Should not be "liked" (isLiked checks for 'up' direction)
+
expect(voteProvider.isLiked(testPostUri), false);
+
+
final voteState = voteProvider.getVoteState(testPostUri);
+
expect(voteState?.direction, 'down');
+
expect(voteState?.uri, 'at://did:plc:test/social.coves.feed.vote/456');
+
expect(voteState?.deleted, false);
+
});
+
+
test('should extract rkey from voteUri', () {
+
voteProvider.setInitialVoteState(
+
postUri: testPostUri,
+
voteDirection: 'up',
+
voteUri: 'at://did:plc:test/social.coves.feed.vote/3kbyxyz123',
+
);
+
+
final voteState = voteProvider.getVoteState(testPostUri);
+
expect(voteState?.rkey, '3kbyxyz123');
+
});
+
+
test('should handle voteUri being null', () {
+
voteProvider.setInitialVoteState(
+
postUri: testPostUri,
+
voteDirection: 'up',
+
);
+
+
final voteState = voteProvider.getVoteState(testPostUri);
+
expect(voteState?.direction, 'up');
+
expect(voteState?.uri, null);
+
expect(voteState?.rkey, null);
+
expect(voteState?.deleted, false);
+
});
+
test('should remove vote state when voteDirection is null', () {
// First set a vote
voteProvider.setInitialVoteState(
···
expect(voteProvider.getVoteState(testPostUri), null);
});
+
test('should clear stale vote state when refreshing with null vote', () {
+
// Simulate initial state from previous session
+
voteProvider.setInitialVoteState(
+
postUri: testPostUri,
+
voteDirection: 'up',
+
voteUri: 'at://did:plc:test/social.coves.feed.vote/456',
+
);
+
+
expect(voteProvider.isLiked(testPostUri), true);
+
+
// Simulate refresh where server returns viewer.vote = null
+
// (user removed vote on another device)
+
voteProvider.setInitialVoteState(
+
postUri: testPostUri,
+
voteDirection: null,
+
);
+
+
// Vote should be cleared
+
expect(voteProvider.isLiked(testPostUri), false);
+
expect(voteProvider.getVoteState(testPostUri), null);
+
});
+
+
test('should clear stale score adjustment on refresh', () async {
+
// Simulate optimistic upvote that created a +1 adjustment
+
when(
+
mockVoteService.createVote(
+
postUri: anyNamed('postUri'),
+
postCid: anyNamed('postCid'),
+
direction: anyNamed('direction'),
+
),
+
).thenAnswer(
+
(_) async => const VoteResponse(
+
uri: 'at://did:plc:test/social.coves.feed.vote/456',
+
cid: 'bafy123',
+
rkey: '456',
+
deleted: false,
+
),
+
);
+
+
// Create vote - this sets _scoreAdjustments[testPostUri] = +1
+
await voteProvider.toggleVote(
+
postUri: testPostUri,
+
postCid: 'bafy2bzacepostcid123',
+
);
+
+
// Verify adjustment exists
+
const serverScore = 10;
+
expect(voteProvider.getAdjustedScore(testPostUri, serverScore), 11);
+
+
// Now simulate a feed refresh - server returns fresh score (11)
+
// which already includes the vote. The adjustment should be cleared.
+
voteProvider.setInitialVoteState(
+
postUri: testPostUri,
+
voteDirection: 'up',
+
voteUri: 'at://did:plc:test/social.coves.feed.vote/456',
+
);
+
+
// After refresh, adjustment should be cleared (server score is truth)
+
// If we pass the NEW server score (11), we should get 11, not 12
+
const freshServerScore = 11;
+
expect(
+
voteProvider.getAdjustedScore(testPostUri, freshServerScore),
+
11,
+
);
+
});
+
test('should not notify listeners when setting initial state', () {
var notificationCount = 0;
voteProvider
···
});
});
+
group('VoteState.extractRkeyFromUri', () {
+
test('should extract rkey from valid AT-URI', () {
+
expect(
+
VoteState.extractRkeyFromUri(
+
'at://did:plc:test/social.coves.feed.vote/3kbyxyz123',
+
),
+
'3kbyxyz123',
+
);
+
});
+
+
test('should return null for null uri', () {
+
expect(VoteState.extractRkeyFromUri(null), null);
+
});
+
+
test('should handle URI with no path segments', () {
+
expect(VoteState.extractRkeyFromUri(''), '');
+
});
+
+
test('should handle complex rkey values', () {
+
expect(
+
VoteState.extractRkeyFromUri(
+
'at://did:plc:abc123xyz/social.coves.feed.vote/3lbp7kw2abc',
+
),
+
'3lbp7kw2abc',
+
);
+
});
+
});
+
group('clear', () {
test('should clear all vote state', () {
const post1 = 'at://did:plc:test/social.coves.post.record/1';
···
postUri: anyNamed('postUri'),
postCid: anyNamed('postCid'),
direction: anyNamed('direction'),
-
existingVoteRkey: anyNamed('existingVoteRkey'),
-
existingVoteDirection: anyNamed('existingVoteDirection'),
),
).thenAnswer((_) async {
await Future.delayed(const Duration(milliseconds: 50));
···
postUri: anyNamed('postUri'),
postCid: anyNamed('postCid'),
direction: anyNamed('direction'),
-
existingVoteRkey: anyNamed('existingVoteRkey'),
-
existingVoteDirection: anyNamed('existingVoteDirection'),
),
).thenAnswer(
(_) async => const VoteResponse(
···
postUri: anyNamed('postUri'),
postCid: anyNamed('postCid'),
direction: anyNamed('direction'),
-
existingVoteRkey: anyNamed('existingVoteRkey'),
-
existingVoteDirection: anyNamed('existingVoteDirection'),
),
).thenAnswer((_) async => const VoteResponse(deleted: true));
···
postUri: anyNamed('postUri'),
postCid: anyNamed('postCid'),
direction: anyNamed('direction'),
-
existingVoteRkey: anyNamed('existingVoteRkey'),
-
existingVoteDirection: anyNamed('existingVoteDirection'),
),
).thenAnswer(
(_) async => const VoteResponse(
···
postUri: anyNamed('postUri'),
postCid: anyNamed('postCid'),
direction: anyNamed('direction'),
-
existingVoteRkey: anyNamed('existingVoteRkey'),
-
existingVoteDirection: anyNamed('existingVoteDirection'),
),
).thenAnswer(
(_) async => const VoteResponse(
···
postUri: anyNamed('postUri'),
postCid: anyNamed('postCid'),
direction: anyNamed('direction'),
-
existingVoteRkey: anyNamed('existingVoteRkey'),
-
existingVoteDirection: anyNamed('existingVoteDirection'),
),
).thenAnswer(
(_) async => const VoteResponse(
···
postUri: anyNamed('postUri'),
postCid: anyNamed('postCid'),
direction: anyNamed('direction'),
-
existingVoteRkey: anyNamed('existingVoteRkey'),
-
existingVoteDirection: anyNamed('existingVoteDirection'),
),
).thenThrow(ApiException('Network error', statusCode: 500));
+1102
test/services/coves_auth_service_environment_test.mocks.dart
···
+
// Mocks generated by Mockito 5.4.6 from annotations
+
// in coves_flutter/test/services/coves_auth_service_environment_test.dart.
+
// Do not manually edit this file.
+
+
// ignore_for_file: no_leading_underscores_for_library_prefixes
+
import 'dart:async' as _i9;
+
+
import 'package:dio/src/adapter.dart' as _i4;
+
import 'package:dio/src/cancel_token.dart' as _i10;
+
import 'package:dio/src/dio.dart' as _i7;
+
import 'package:dio/src/dio_mixin.dart' as _i3;
+
import 'package:dio/src/options.dart' as _i2;
+
import 'package:dio/src/response.dart' as _i6;
+
import 'package:dio/src/transformer.dart' as _i5;
+
import 'package:flutter/foundation.dart' as _i11;
+
import 'package:flutter_secure_storage/flutter_secure_storage.dart' as _i8;
+
import 'package:mockito/mockito.dart' as _i1;
+
+
// ignore_for_file: type=lint
+
// ignore_for_file: avoid_redundant_argument_values
+
// ignore_for_file: avoid_setters_without_getters
+
// ignore_for_file: comment_references
+
// ignore_for_file: deprecated_member_use
+
// ignore_for_file: deprecated_member_use_from_same_package
+
// ignore_for_file: implementation_imports
+
// ignore_for_file: invalid_use_of_visible_for_testing_member
+
// ignore_for_file: must_be_immutable
+
// ignore_for_file: prefer_const_constructors
+
// ignore_for_file: unnecessary_parenthesis
+
// ignore_for_file: camel_case_types
+
// ignore_for_file: subtype_of_sealed_class
+
// ignore_for_file: invalid_use_of_internal_member
+
+
class _FakeBaseOptions_0 extends _i1.SmartFake implements _i2.BaseOptions {
+
_FakeBaseOptions_0(Object parent, Invocation parentInvocation)
+
: super(parent, parentInvocation);
+
}
+
+
class _FakeInterceptors_1 extends _i1.SmartFake implements _i3.Interceptors {
+
_FakeInterceptors_1(Object parent, Invocation parentInvocation)
+
: super(parent, parentInvocation);
+
}
+
+
class _FakeHttpClientAdapter_2 extends _i1.SmartFake
+
implements _i4.HttpClientAdapter {
+
_FakeHttpClientAdapter_2(Object parent, Invocation parentInvocation)
+
: super(parent, parentInvocation);
+
}
+
+
class _FakeTransformer_3 extends _i1.SmartFake implements _i5.Transformer {
+
_FakeTransformer_3(Object parent, Invocation parentInvocation)
+
: super(parent, parentInvocation);
+
}
+
+
class _FakeResponse_4<T1> extends _i1.SmartFake implements _i6.Response<T1> {
+
_FakeResponse_4(Object parent, Invocation parentInvocation)
+
: super(parent, parentInvocation);
+
}
+
+
class _FakeDio_5 extends _i1.SmartFake implements _i7.Dio {
+
_FakeDio_5(Object parent, Invocation parentInvocation)
+
: super(parent, parentInvocation);
+
}
+
+
class _FakeIOSOptions_6 extends _i1.SmartFake implements _i8.IOSOptions {
+
_FakeIOSOptions_6(Object parent, Invocation parentInvocation)
+
: super(parent, parentInvocation);
+
}
+
+
class _FakeAndroidOptions_7 extends _i1.SmartFake
+
implements _i8.AndroidOptions {
+
_FakeAndroidOptions_7(Object parent, Invocation parentInvocation)
+
: super(parent, parentInvocation);
+
}
+
+
class _FakeLinuxOptions_8 extends _i1.SmartFake implements _i8.LinuxOptions {
+
_FakeLinuxOptions_8(Object parent, Invocation parentInvocation)
+
: super(parent, parentInvocation);
+
}
+
+
class _FakeWindowsOptions_9 extends _i1.SmartFake
+
implements _i8.WindowsOptions {
+
_FakeWindowsOptions_9(Object parent, Invocation parentInvocation)
+
: super(parent, parentInvocation);
+
}
+
+
class _FakeWebOptions_10 extends _i1.SmartFake implements _i8.WebOptions {
+
_FakeWebOptions_10(Object parent, Invocation parentInvocation)
+
: super(parent, parentInvocation);
+
}
+
+
class _FakeMacOsOptions_11 extends _i1.SmartFake implements _i8.MacOsOptions {
+
_FakeMacOsOptions_11(Object parent, Invocation parentInvocation)
+
: super(parent, parentInvocation);
+
}
+
+
/// A class which mocks [Dio].
+
///
+
/// See the documentation for Mockito's code generation for more information.
+
class MockDio extends _i1.Mock implements _i7.Dio {
+
MockDio() {
+
_i1.throwOnMissingStub(this);
+
}
+
+
@override
+
_i2.BaseOptions get options =>
+
(super.noSuchMethod(
+
Invocation.getter(#options),
+
returnValue: _FakeBaseOptions_0(this, Invocation.getter(#options)),
+
)
+
as _i2.BaseOptions);
+
+
@override
+
_i3.Interceptors get interceptors =>
+
(super.noSuchMethod(
+
Invocation.getter(#interceptors),
+
returnValue: _FakeInterceptors_1(
+
this,
+
Invocation.getter(#interceptors),
+
),
+
)
+
as _i3.Interceptors);
+
+
@override
+
_i4.HttpClientAdapter get httpClientAdapter =>
+
(super.noSuchMethod(
+
Invocation.getter(#httpClientAdapter),
+
returnValue: _FakeHttpClientAdapter_2(
+
this,
+
Invocation.getter(#httpClientAdapter),
+
),
+
)
+
as _i4.HttpClientAdapter);
+
+
@override
+
_i5.Transformer get transformer =>
+
(super.noSuchMethod(
+
Invocation.getter(#transformer),
+
returnValue: _FakeTransformer_3(
+
this,
+
Invocation.getter(#transformer),
+
),
+
)
+
as _i5.Transformer);
+
+
@override
+
set options(_i2.BaseOptions? value) => super.noSuchMethod(
+
Invocation.setter(#options, value),
+
returnValueForMissingStub: null,
+
);
+
+
@override
+
set httpClientAdapter(_i4.HttpClientAdapter? value) => super.noSuchMethod(
+
Invocation.setter(#httpClientAdapter, value),
+
returnValueForMissingStub: null,
+
);
+
+
@override
+
set transformer(_i5.Transformer? value) => super.noSuchMethod(
+
Invocation.setter(#transformer, value),
+
returnValueForMissingStub: null,
+
);
+
+
@override
+
void close({bool? force = false}) => super.noSuchMethod(
+
Invocation.method(#close, [], {#force: force}),
+
returnValueForMissingStub: null,
+
);
+
+
@override
+
_i9.Future<_i6.Response<T>> head<T>(
+
String? path, {
+
Object? data,
+
Map<String, dynamic>? queryParameters,
+
_i2.Options? options,
+
_i10.CancelToken? cancelToken,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#head,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
},
+
),
+
returnValue: _i9.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#head,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
},
+
),
+
),
+
),
+
)
+
as _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i9.Future<_i6.Response<T>> headUri<T>(
+
Uri? uri, {
+
Object? data,
+
_i2.Options? options,
+
_i10.CancelToken? cancelToken,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#headUri,
+
[uri],
+
{#data: data, #options: options, #cancelToken: cancelToken},
+
),
+
returnValue: _i9.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#headUri,
+
[uri],
+
{#data: data, #options: options, #cancelToken: cancelToken},
+
),
+
),
+
),
+
)
+
as _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i9.Future<_i6.Response<T>> get<T>(
+
String? path, {
+
Object? data,
+
Map<String, dynamic>? queryParameters,
+
_i2.Options? options,
+
_i10.CancelToken? cancelToken,
+
_i2.ProgressCallback? onReceiveProgress,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#get,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
returnValue: _i9.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#get,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
),
+
),
+
)
+
as _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i9.Future<_i6.Response<T>> getUri<T>(
+
Uri? uri, {
+
Object? data,
+
_i2.Options? options,
+
_i10.CancelToken? cancelToken,
+
_i2.ProgressCallback? onReceiveProgress,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#getUri,
+
[uri],
+
{
+
#data: data,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
returnValue: _i9.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#getUri,
+
[uri],
+
{
+
#data: data,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
),
+
),
+
)
+
as _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i9.Future<_i6.Response<T>> post<T>(
+
String? path, {
+
Object? data,
+
Map<String, dynamic>? queryParameters,
+
_i2.Options? options,
+
_i10.CancelToken? cancelToken,
+
_i2.ProgressCallback? onSendProgress,
+
_i2.ProgressCallback? onReceiveProgress,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#post,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
returnValue: _i9.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#post,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
),
+
),
+
)
+
as _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i9.Future<_i6.Response<T>> postUri<T>(
+
Uri? uri, {
+
Object? data,
+
_i2.Options? options,
+
_i10.CancelToken? cancelToken,
+
_i2.ProgressCallback? onSendProgress,
+
_i2.ProgressCallback? onReceiveProgress,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#postUri,
+
[uri],
+
{
+
#data: data,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
returnValue: _i9.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#postUri,
+
[uri],
+
{
+
#data: data,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
),
+
),
+
)
+
as _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i9.Future<_i6.Response<T>> put<T>(
+
String? path, {
+
Object? data,
+
Map<String, dynamic>? queryParameters,
+
_i2.Options? options,
+
_i10.CancelToken? cancelToken,
+
_i2.ProgressCallback? onSendProgress,
+
_i2.ProgressCallback? onReceiveProgress,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#put,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
returnValue: _i9.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#put,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
),
+
),
+
)
+
as _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i9.Future<_i6.Response<T>> putUri<T>(
+
Uri? uri, {
+
Object? data,
+
_i2.Options? options,
+
_i10.CancelToken? cancelToken,
+
_i2.ProgressCallback? onSendProgress,
+
_i2.ProgressCallback? onReceiveProgress,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#putUri,
+
[uri],
+
{
+
#data: data,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
returnValue: _i9.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#putUri,
+
[uri],
+
{
+
#data: data,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
),
+
),
+
)
+
as _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i9.Future<_i6.Response<T>> patch<T>(
+
String? path, {
+
Object? data,
+
Map<String, dynamic>? queryParameters,
+
_i2.Options? options,
+
_i10.CancelToken? cancelToken,
+
_i2.ProgressCallback? onSendProgress,
+
_i2.ProgressCallback? onReceiveProgress,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#patch,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
returnValue: _i9.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#patch,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
),
+
),
+
)
+
as _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i9.Future<_i6.Response<T>> patchUri<T>(
+
Uri? uri, {
+
Object? data,
+
_i2.Options? options,
+
_i10.CancelToken? cancelToken,
+
_i2.ProgressCallback? onSendProgress,
+
_i2.ProgressCallback? onReceiveProgress,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#patchUri,
+
[uri],
+
{
+
#data: data,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
returnValue: _i9.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#patchUri,
+
[uri],
+
{
+
#data: data,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
),
+
),
+
)
+
as _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i9.Future<_i6.Response<T>> delete<T>(
+
String? path, {
+
Object? data,
+
Map<String, dynamic>? queryParameters,
+
_i2.Options? options,
+
_i10.CancelToken? cancelToken,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#delete,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
},
+
),
+
returnValue: _i9.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#delete,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
},
+
),
+
),
+
),
+
)
+
as _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i9.Future<_i6.Response<T>> deleteUri<T>(
+
Uri? uri, {
+
Object? data,
+
_i2.Options? options,
+
_i10.CancelToken? cancelToken,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#deleteUri,
+
[uri],
+
{#data: data, #options: options, #cancelToken: cancelToken},
+
),
+
returnValue: _i9.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#deleteUri,
+
[uri],
+
{#data: data, #options: options, #cancelToken: cancelToken},
+
),
+
),
+
),
+
)
+
as _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i9.Future<_i6.Response<dynamic>> download(
+
String? urlPath,
+
dynamic savePath, {
+
_i2.ProgressCallback? onReceiveProgress,
+
Map<String, dynamic>? queryParameters,
+
_i10.CancelToken? cancelToken,
+
bool? deleteOnError = true,
+
_i2.FileAccessMode? fileAccessMode = _i2.FileAccessMode.write,
+
String? lengthHeader = 'content-length',
+
Object? data,
+
_i2.Options? options,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#download,
+
[urlPath, savePath],
+
{
+
#onReceiveProgress: onReceiveProgress,
+
#queryParameters: queryParameters,
+
#cancelToken: cancelToken,
+
#deleteOnError: deleteOnError,
+
#fileAccessMode: fileAccessMode,
+
#lengthHeader: lengthHeader,
+
#data: data,
+
#options: options,
+
},
+
),
+
returnValue: _i9.Future<_i6.Response<dynamic>>.value(
+
_FakeResponse_4<dynamic>(
+
this,
+
Invocation.method(
+
#download,
+
[urlPath, savePath],
+
{
+
#onReceiveProgress: onReceiveProgress,
+
#queryParameters: queryParameters,
+
#cancelToken: cancelToken,
+
#deleteOnError: deleteOnError,
+
#fileAccessMode: fileAccessMode,
+
#lengthHeader: lengthHeader,
+
#data: data,
+
#options: options,
+
},
+
),
+
),
+
),
+
)
+
as _i9.Future<_i6.Response<dynamic>>);
+
+
@override
+
_i9.Future<_i6.Response<dynamic>> downloadUri(
+
Uri? uri,
+
dynamic savePath, {
+
_i2.ProgressCallback? onReceiveProgress,
+
_i10.CancelToken? cancelToken,
+
bool? deleteOnError = true,
+
_i2.FileAccessMode? fileAccessMode = _i2.FileAccessMode.write,
+
String? lengthHeader = 'content-length',
+
Object? data,
+
_i2.Options? options,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#downloadUri,
+
[uri, savePath],
+
{
+
#onReceiveProgress: onReceiveProgress,
+
#cancelToken: cancelToken,
+
#deleteOnError: deleteOnError,
+
#fileAccessMode: fileAccessMode,
+
#lengthHeader: lengthHeader,
+
#data: data,
+
#options: options,
+
},
+
),
+
returnValue: _i9.Future<_i6.Response<dynamic>>.value(
+
_FakeResponse_4<dynamic>(
+
this,
+
Invocation.method(
+
#downloadUri,
+
[uri, savePath],
+
{
+
#onReceiveProgress: onReceiveProgress,
+
#cancelToken: cancelToken,
+
#deleteOnError: deleteOnError,
+
#fileAccessMode: fileAccessMode,
+
#lengthHeader: lengthHeader,
+
#data: data,
+
#options: options,
+
},
+
),
+
),
+
),
+
)
+
as _i9.Future<_i6.Response<dynamic>>);
+
+
@override
+
_i9.Future<_i6.Response<T>> request<T>(
+
String? url, {
+
Object? data,
+
Map<String, dynamic>? queryParameters,
+
_i10.CancelToken? cancelToken,
+
_i2.Options? options,
+
_i2.ProgressCallback? onSendProgress,
+
_i2.ProgressCallback? onReceiveProgress,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#request,
+
[url],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#cancelToken: cancelToken,
+
#options: options,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
returnValue: _i9.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#request,
+
[url],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#cancelToken: cancelToken,
+
#options: options,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
),
+
),
+
)
+
as _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i9.Future<_i6.Response<T>> requestUri<T>(
+
Uri? uri, {
+
Object? data,
+
_i10.CancelToken? cancelToken,
+
_i2.Options? options,
+
_i2.ProgressCallback? onSendProgress,
+
_i2.ProgressCallback? onReceiveProgress,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#requestUri,
+
[uri],
+
{
+
#data: data,
+
#cancelToken: cancelToken,
+
#options: options,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
returnValue: _i9.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#requestUri,
+
[uri],
+
{
+
#data: data,
+
#cancelToken: cancelToken,
+
#options: options,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
),
+
),
+
)
+
as _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i9.Future<_i6.Response<T>> fetch<T>(_i2.RequestOptions? requestOptions) =>
+
(super.noSuchMethod(
+
Invocation.method(#fetch, [requestOptions]),
+
returnValue: _i9.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(#fetch, [requestOptions]),
+
),
+
),
+
)
+
as _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i7.Dio clone({
+
_i2.BaseOptions? options,
+
_i3.Interceptors? interceptors,
+
_i4.HttpClientAdapter? httpClientAdapter,
+
_i5.Transformer? transformer,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(#clone, [], {
+
#options: options,
+
#interceptors: interceptors,
+
#httpClientAdapter: httpClientAdapter,
+
#transformer: transformer,
+
}),
+
returnValue: _FakeDio_5(
+
this,
+
Invocation.method(#clone, [], {
+
#options: options,
+
#interceptors: interceptors,
+
#httpClientAdapter: httpClientAdapter,
+
#transformer: transformer,
+
}),
+
),
+
)
+
as _i7.Dio);
+
}
+
+
/// A class which mocks [FlutterSecureStorage].
+
///
+
/// See the documentation for Mockito's code generation for more information.
+
class MockFlutterSecureStorage extends _i1.Mock
+
implements _i8.FlutterSecureStorage {
+
MockFlutterSecureStorage() {
+
_i1.throwOnMissingStub(this);
+
}
+
+
@override
+
_i8.IOSOptions get iOptions =>
+
(super.noSuchMethod(
+
Invocation.getter(#iOptions),
+
returnValue: _FakeIOSOptions_6(this, Invocation.getter(#iOptions)),
+
)
+
as _i8.IOSOptions);
+
+
@override
+
_i8.AndroidOptions get aOptions =>
+
(super.noSuchMethod(
+
Invocation.getter(#aOptions),
+
returnValue: _FakeAndroidOptions_7(
+
this,
+
Invocation.getter(#aOptions),
+
),
+
)
+
as _i8.AndroidOptions);
+
+
@override
+
_i8.LinuxOptions get lOptions =>
+
(super.noSuchMethod(
+
Invocation.getter(#lOptions),
+
returnValue: _FakeLinuxOptions_8(
+
this,
+
Invocation.getter(#lOptions),
+
),
+
)
+
as _i8.LinuxOptions);
+
+
@override
+
_i8.WindowsOptions get wOptions =>
+
(super.noSuchMethod(
+
Invocation.getter(#wOptions),
+
returnValue: _FakeWindowsOptions_9(
+
this,
+
Invocation.getter(#wOptions),
+
),
+
)
+
as _i8.WindowsOptions);
+
+
@override
+
_i8.WebOptions get webOptions =>
+
(super.noSuchMethod(
+
Invocation.getter(#webOptions),
+
returnValue: _FakeWebOptions_10(
+
this,
+
Invocation.getter(#webOptions),
+
),
+
)
+
as _i8.WebOptions);
+
+
@override
+
_i8.MacOsOptions get mOptions =>
+
(super.noSuchMethod(
+
Invocation.getter(#mOptions),
+
returnValue: _FakeMacOsOptions_11(
+
this,
+
Invocation.getter(#mOptions),
+
),
+
)
+
as _i8.MacOsOptions);
+
+
@override
+
void registerListener({
+
required String? key,
+
required _i11.ValueChanged<String?>? listener,
+
}) => super.noSuchMethod(
+
Invocation.method(#registerListener, [], {#key: key, #listener: listener}),
+
returnValueForMissingStub: null,
+
);
+
+
@override
+
void unregisterListener({
+
required String? key,
+
required _i11.ValueChanged<String?>? listener,
+
}) => super.noSuchMethod(
+
Invocation.method(#unregisterListener, [], {
+
#key: key,
+
#listener: listener,
+
}),
+
returnValueForMissingStub: null,
+
);
+
+
@override
+
void unregisterAllListenersForKey({required String? key}) =>
+
super.noSuchMethod(
+
Invocation.method(#unregisterAllListenersForKey, [], {#key: key}),
+
returnValueForMissingStub: null,
+
);
+
+
@override
+
void unregisterAllListeners() => super.noSuchMethod(
+
Invocation.method(#unregisterAllListeners, []),
+
returnValueForMissingStub: null,
+
);
+
+
@override
+
_i9.Future<void> write({
+
required String? key,
+
required String? value,
+
_i8.IOSOptions? iOptions,
+
_i8.AndroidOptions? aOptions,
+
_i8.LinuxOptions? lOptions,
+
_i8.WebOptions? webOptions,
+
_i8.MacOsOptions? mOptions,
+
_i8.WindowsOptions? wOptions,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(#write, [], {
+
#key: key,
+
#value: value,
+
#iOptions: iOptions,
+
#aOptions: aOptions,
+
#lOptions: lOptions,
+
#webOptions: webOptions,
+
#mOptions: mOptions,
+
#wOptions: wOptions,
+
}),
+
returnValue: _i9.Future<void>.value(),
+
returnValueForMissingStub: _i9.Future<void>.value(),
+
)
+
as _i9.Future<void>);
+
+
@override
+
_i9.Future<String?> read({
+
required String? key,
+
_i8.IOSOptions? iOptions,
+
_i8.AndroidOptions? aOptions,
+
_i8.LinuxOptions? lOptions,
+
_i8.WebOptions? webOptions,
+
_i8.MacOsOptions? mOptions,
+
_i8.WindowsOptions? wOptions,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(#read, [], {
+
#key: key,
+
#iOptions: iOptions,
+
#aOptions: aOptions,
+
#lOptions: lOptions,
+
#webOptions: webOptions,
+
#mOptions: mOptions,
+
#wOptions: wOptions,
+
}),
+
returnValue: _i9.Future<String?>.value(),
+
)
+
as _i9.Future<String?>);
+
+
@override
+
_i9.Future<bool> containsKey({
+
required String? key,
+
_i8.IOSOptions? iOptions,
+
_i8.AndroidOptions? aOptions,
+
_i8.LinuxOptions? lOptions,
+
_i8.WebOptions? webOptions,
+
_i8.MacOsOptions? mOptions,
+
_i8.WindowsOptions? wOptions,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(#containsKey, [], {
+
#key: key,
+
#iOptions: iOptions,
+
#aOptions: aOptions,
+
#lOptions: lOptions,
+
#webOptions: webOptions,
+
#mOptions: mOptions,
+
#wOptions: wOptions,
+
}),
+
returnValue: _i9.Future<bool>.value(false),
+
)
+
as _i9.Future<bool>);
+
+
@override
+
_i9.Future<void> delete({
+
required String? key,
+
_i8.IOSOptions? iOptions,
+
_i8.AndroidOptions? aOptions,
+
_i8.LinuxOptions? lOptions,
+
_i8.WebOptions? webOptions,
+
_i8.MacOsOptions? mOptions,
+
_i8.WindowsOptions? wOptions,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(#delete, [], {
+
#key: key,
+
#iOptions: iOptions,
+
#aOptions: aOptions,
+
#lOptions: lOptions,
+
#webOptions: webOptions,
+
#mOptions: mOptions,
+
#wOptions: wOptions,
+
}),
+
returnValue: _i9.Future<void>.value(),
+
returnValueForMissingStub: _i9.Future<void>.value(),
+
)
+
as _i9.Future<void>);
+
+
@override
+
_i9.Future<Map<String, String>> readAll({
+
_i8.IOSOptions? iOptions,
+
_i8.AndroidOptions? aOptions,
+
_i8.LinuxOptions? lOptions,
+
_i8.WebOptions? webOptions,
+
_i8.MacOsOptions? mOptions,
+
_i8.WindowsOptions? wOptions,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(#readAll, [], {
+
#iOptions: iOptions,
+
#aOptions: aOptions,
+
#lOptions: lOptions,
+
#webOptions: webOptions,
+
#mOptions: mOptions,
+
#wOptions: wOptions,
+
}),
+
returnValue: _i9.Future<Map<String, String>>.value(
+
<String, String>{},
+
),
+
)
+
as _i9.Future<Map<String, String>>);
+
+
@override
+
_i9.Future<void> deleteAll({
+
_i8.IOSOptions? iOptions,
+
_i8.AndroidOptions? aOptions,
+
_i8.LinuxOptions? lOptions,
+
_i8.WebOptions? webOptions,
+
_i8.MacOsOptions? mOptions,
+
_i8.WindowsOptions? wOptions,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(#deleteAll, [], {
+
#iOptions: iOptions,
+
#aOptions: aOptions,
+
#lOptions: lOptions,
+
#webOptions: webOptions,
+
#mOptions: mOptions,
+
#wOptions: wOptions,
+
}),
+
returnValue: _i9.Future<void>.value(),
+
returnValueForMissingStub: _i9.Future<void>.value(),
+
)
+
as _i9.Future<void>);
+
+
@override
+
_i9.Future<bool?> isCupertinoProtectedDataAvailable() =>
+
(super.noSuchMethod(
+
Invocation.method(#isCupertinoProtectedDataAvailable, []),
+
returnValue: _i9.Future<bool?>.value(),
+
)
+
as _i9.Future<bool?>);
+
}
+1102
test/services/coves_auth_service_singleton_test.mocks.dart
···
+
// Mocks generated by Mockito 5.4.6 from annotations
+
// in coves_flutter/test/services/coves_auth_service_singleton_test.dart.
+
// Do not manually edit this file.
+
+
// ignore_for_file: no_leading_underscores_for_library_prefixes
+
import 'dart:async' as _i9;
+
+
import 'package:dio/src/adapter.dart' as _i4;
+
import 'package:dio/src/cancel_token.dart' as _i10;
+
import 'package:dio/src/dio.dart' as _i7;
+
import 'package:dio/src/dio_mixin.dart' as _i3;
+
import 'package:dio/src/options.dart' as _i2;
+
import 'package:dio/src/response.dart' as _i6;
+
import 'package:dio/src/transformer.dart' as _i5;
+
import 'package:flutter/foundation.dart' as _i11;
+
import 'package:flutter_secure_storage/flutter_secure_storage.dart' as _i8;
+
import 'package:mockito/mockito.dart' as _i1;
+
+
// ignore_for_file: type=lint
+
// ignore_for_file: avoid_redundant_argument_values
+
// ignore_for_file: avoid_setters_without_getters
+
// ignore_for_file: comment_references
+
// ignore_for_file: deprecated_member_use
+
// ignore_for_file: deprecated_member_use_from_same_package
+
// ignore_for_file: implementation_imports
+
// ignore_for_file: invalid_use_of_visible_for_testing_member
+
// ignore_for_file: must_be_immutable
+
// ignore_for_file: prefer_const_constructors
+
// ignore_for_file: unnecessary_parenthesis
+
// ignore_for_file: camel_case_types
+
// ignore_for_file: subtype_of_sealed_class
+
// ignore_for_file: invalid_use_of_internal_member
+
+
class _FakeBaseOptions_0 extends _i1.SmartFake implements _i2.BaseOptions {
+
_FakeBaseOptions_0(Object parent, Invocation parentInvocation)
+
: super(parent, parentInvocation);
+
}
+
+
class _FakeInterceptors_1 extends _i1.SmartFake implements _i3.Interceptors {
+
_FakeInterceptors_1(Object parent, Invocation parentInvocation)
+
: super(parent, parentInvocation);
+
}
+
+
class _FakeHttpClientAdapter_2 extends _i1.SmartFake
+
implements _i4.HttpClientAdapter {
+
_FakeHttpClientAdapter_2(Object parent, Invocation parentInvocation)
+
: super(parent, parentInvocation);
+
}
+
+
class _FakeTransformer_3 extends _i1.SmartFake implements _i5.Transformer {
+
_FakeTransformer_3(Object parent, Invocation parentInvocation)
+
: super(parent, parentInvocation);
+
}
+
+
class _FakeResponse_4<T1> extends _i1.SmartFake implements _i6.Response<T1> {
+
_FakeResponse_4(Object parent, Invocation parentInvocation)
+
: super(parent, parentInvocation);
+
}
+
+
class _FakeDio_5 extends _i1.SmartFake implements _i7.Dio {
+
_FakeDio_5(Object parent, Invocation parentInvocation)
+
: super(parent, parentInvocation);
+
}
+
+
class _FakeIOSOptions_6 extends _i1.SmartFake implements _i8.IOSOptions {
+
_FakeIOSOptions_6(Object parent, Invocation parentInvocation)
+
: super(parent, parentInvocation);
+
}
+
+
class _FakeAndroidOptions_7 extends _i1.SmartFake
+
implements _i8.AndroidOptions {
+
_FakeAndroidOptions_7(Object parent, Invocation parentInvocation)
+
: super(parent, parentInvocation);
+
}
+
+
class _FakeLinuxOptions_8 extends _i1.SmartFake implements _i8.LinuxOptions {
+
_FakeLinuxOptions_8(Object parent, Invocation parentInvocation)
+
: super(parent, parentInvocation);
+
}
+
+
class _FakeWindowsOptions_9 extends _i1.SmartFake
+
implements _i8.WindowsOptions {
+
_FakeWindowsOptions_9(Object parent, Invocation parentInvocation)
+
: super(parent, parentInvocation);
+
}
+
+
class _FakeWebOptions_10 extends _i1.SmartFake implements _i8.WebOptions {
+
_FakeWebOptions_10(Object parent, Invocation parentInvocation)
+
: super(parent, parentInvocation);
+
}
+
+
class _FakeMacOsOptions_11 extends _i1.SmartFake implements _i8.MacOsOptions {
+
_FakeMacOsOptions_11(Object parent, Invocation parentInvocation)
+
: super(parent, parentInvocation);
+
}
+
+
/// A class which mocks [Dio].
+
///
+
/// See the documentation for Mockito's code generation for more information.
+
class MockDio extends _i1.Mock implements _i7.Dio {
+
MockDio() {
+
_i1.throwOnMissingStub(this);
+
}
+
+
@override
+
_i2.BaseOptions get options =>
+
(super.noSuchMethod(
+
Invocation.getter(#options),
+
returnValue: _FakeBaseOptions_0(this, Invocation.getter(#options)),
+
)
+
as _i2.BaseOptions);
+
+
@override
+
_i3.Interceptors get interceptors =>
+
(super.noSuchMethod(
+
Invocation.getter(#interceptors),
+
returnValue: _FakeInterceptors_1(
+
this,
+
Invocation.getter(#interceptors),
+
),
+
)
+
as _i3.Interceptors);
+
+
@override
+
_i4.HttpClientAdapter get httpClientAdapter =>
+
(super.noSuchMethod(
+
Invocation.getter(#httpClientAdapter),
+
returnValue: _FakeHttpClientAdapter_2(
+
this,
+
Invocation.getter(#httpClientAdapter),
+
),
+
)
+
as _i4.HttpClientAdapter);
+
+
@override
+
_i5.Transformer get transformer =>
+
(super.noSuchMethod(
+
Invocation.getter(#transformer),
+
returnValue: _FakeTransformer_3(
+
this,
+
Invocation.getter(#transformer),
+
),
+
)
+
as _i5.Transformer);
+
+
@override
+
set options(_i2.BaseOptions? value) => super.noSuchMethod(
+
Invocation.setter(#options, value),
+
returnValueForMissingStub: null,
+
);
+
+
@override
+
set httpClientAdapter(_i4.HttpClientAdapter? value) => super.noSuchMethod(
+
Invocation.setter(#httpClientAdapter, value),
+
returnValueForMissingStub: null,
+
);
+
+
@override
+
set transformer(_i5.Transformer? value) => super.noSuchMethod(
+
Invocation.setter(#transformer, value),
+
returnValueForMissingStub: null,
+
);
+
+
@override
+
void close({bool? force = false}) => super.noSuchMethod(
+
Invocation.method(#close, [], {#force: force}),
+
returnValueForMissingStub: null,
+
);
+
+
@override
+
_i9.Future<_i6.Response<T>> head<T>(
+
String? path, {
+
Object? data,
+
Map<String, dynamic>? queryParameters,
+
_i2.Options? options,
+
_i10.CancelToken? cancelToken,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#head,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
},
+
),
+
returnValue: _i9.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#head,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
},
+
),
+
),
+
),
+
)
+
as _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i9.Future<_i6.Response<T>> headUri<T>(
+
Uri? uri, {
+
Object? data,
+
_i2.Options? options,
+
_i10.CancelToken? cancelToken,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#headUri,
+
[uri],
+
{#data: data, #options: options, #cancelToken: cancelToken},
+
),
+
returnValue: _i9.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#headUri,
+
[uri],
+
{#data: data, #options: options, #cancelToken: cancelToken},
+
),
+
),
+
),
+
)
+
as _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i9.Future<_i6.Response<T>> get<T>(
+
String? path, {
+
Object? data,
+
Map<String, dynamic>? queryParameters,
+
_i2.Options? options,
+
_i10.CancelToken? cancelToken,
+
_i2.ProgressCallback? onReceiveProgress,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#get,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
returnValue: _i9.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#get,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
),
+
),
+
)
+
as _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i9.Future<_i6.Response<T>> getUri<T>(
+
Uri? uri, {
+
Object? data,
+
_i2.Options? options,
+
_i10.CancelToken? cancelToken,
+
_i2.ProgressCallback? onReceiveProgress,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#getUri,
+
[uri],
+
{
+
#data: data,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
returnValue: _i9.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#getUri,
+
[uri],
+
{
+
#data: data,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
),
+
),
+
)
+
as _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i9.Future<_i6.Response<T>> post<T>(
+
String? path, {
+
Object? data,
+
Map<String, dynamic>? queryParameters,
+
_i2.Options? options,
+
_i10.CancelToken? cancelToken,
+
_i2.ProgressCallback? onSendProgress,
+
_i2.ProgressCallback? onReceiveProgress,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#post,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
returnValue: _i9.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#post,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
),
+
),
+
)
+
as _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i9.Future<_i6.Response<T>> postUri<T>(
+
Uri? uri, {
+
Object? data,
+
_i2.Options? options,
+
_i10.CancelToken? cancelToken,
+
_i2.ProgressCallback? onSendProgress,
+
_i2.ProgressCallback? onReceiveProgress,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#postUri,
+
[uri],
+
{
+
#data: data,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
returnValue: _i9.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#postUri,
+
[uri],
+
{
+
#data: data,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
),
+
),
+
)
+
as _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i9.Future<_i6.Response<T>> put<T>(
+
String? path, {
+
Object? data,
+
Map<String, dynamic>? queryParameters,
+
_i2.Options? options,
+
_i10.CancelToken? cancelToken,
+
_i2.ProgressCallback? onSendProgress,
+
_i2.ProgressCallback? onReceiveProgress,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#put,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
returnValue: _i9.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#put,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
),
+
),
+
)
+
as _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i9.Future<_i6.Response<T>> putUri<T>(
+
Uri? uri, {
+
Object? data,
+
_i2.Options? options,
+
_i10.CancelToken? cancelToken,
+
_i2.ProgressCallback? onSendProgress,
+
_i2.ProgressCallback? onReceiveProgress,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#putUri,
+
[uri],
+
{
+
#data: data,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
returnValue: _i9.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#putUri,
+
[uri],
+
{
+
#data: data,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
),
+
),
+
)
+
as _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i9.Future<_i6.Response<T>> patch<T>(
+
String? path, {
+
Object? data,
+
Map<String, dynamic>? queryParameters,
+
_i2.Options? options,
+
_i10.CancelToken? cancelToken,
+
_i2.ProgressCallback? onSendProgress,
+
_i2.ProgressCallback? onReceiveProgress,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#patch,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
returnValue: _i9.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#patch,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
),
+
),
+
)
+
as _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i9.Future<_i6.Response<T>> patchUri<T>(
+
Uri? uri, {
+
Object? data,
+
_i2.Options? options,
+
_i10.CancelToken? cancelToken,
+
_i2.ProgressCallback? onSendProgress,
+
_i2.ProgressCallback? onReceiveProgress,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#patchUri,
+
[uri],
+
{
+
#data: data,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
returnValue: _i9.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#patchUri,
+
[uri],
+
{
+
#data: data,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
),
+
),
+
)
+
as _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i9.Future<_i6.Response<T>> delete<T>(
+
String? path, {
+
Object? data,
+
Map<String, dynamic>? queryParameters,
+
_i2.Options? options,
+
_i10.CancelToken? cancelToken,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#delete,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
},
+
),
+
returnValue: _i9.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#delete,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
},
+
),
+
),
+
),
+
)
+
as _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i9.Future<_i6.Response<T>> deleteUri<T>(
+
Uri? uri, {
+
Object? data,
+
_i2.Options? options,
+
_i10.CancelToken? cancelToken,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#deleteUri,
+
[uri],
+
{#data: data, #options: options, #cancelToken: cancelToken},
+
),
+
returnValue: _i9.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#deleteUri,
+
[uri],
+
{#data: data, #options: options, #cancelToken: cancelToken},
+
),
+
),
+
),
+
)
+
as _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i9.Future<_i6.Response<dynamic>> download(
+
String? urlPath,
+
dynamic savePath, {
+
_i2.ProgressCallback? onReceiveProgress,
+
Map<String, dynamic>? queryParameters,
+
_i10.CancelToken? cancelToken,
+
bool? deleteOnError = true,
+
_i2.FileAccessMode? fileAccessMode = _i2.FileAccessMode.write,
+
String? lengthHeader = 'content-length',
+
Object? data,
+
_i2.Options? options,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#download,
+
[urlPath, savePath],
+
{
+
#onReceiveProgress: onReceiveProgress,
+
#queryParameters: queryParameters,
+
#cancelToken: cancelToken,
+
#deleteOnError: deleteOnError,
+
#fileAccessMode: fileAccessMode,
+
#lengthHeader: lengthHeader,
+
#data: data,
+
#options: options,
+
},
+
),
+
returnValue: _i9.Future<_i6.Response<dynamic>>.value(
+
_FakeResponse_4<dynamic>(
+
this,
+
Invocation.method(
+
#download,
+
[urlPath, savePath],
+
{
+
#onReceiveProgress: onReceiveProgress,
+
#queryParameters: queryParameters,
+
#cancelToken: cancelToken,
+
#deleteOnError: deleteOnError,
+
#fileAccessMode: fileAccessMode,
+
#lengthHeader: lengthHeader,
+
#data: data,
+
#options: options,
+
},
+
),
+
),
+
),
+
)
+
as _i9.Future<_i6.Response<dynamic>>);
+
+
@override
+
_i9.Future<_i6.Response<dynamic>> downloadUri(
+
Uri? uri,
+
dynamic savePath, {
+
_i2.ProgressCallback? onReceiveProgress,
+
_i10.CancelToken? cancelToken,
+
bool? deleteOnError = true,
+
_i2.FileAccessMode? fileAccessMode = _i2.FileAccessMode.write,
+
String? lengthHeader = 'content-length',
+
Object? data,
+
_i2.Options? options,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#downloadUri,
+
[uri, savePath],
+
{
+
#onReceiveProgress: onReceiveProgress,
+
#cancelToken: cancelToken,
+
#deleteOnError: deleteOnError,
+
#fileAccessMode: fileAccessMode,
+
#lengthHeader: lengthHeader,
+
#data: data,
+
#options: options,
+
},
+
),
+
returnValue: _i9.Future<_i6.Response<dynamic>>.value(
+
_FakeResponse_4<dynamic>(
+
this,
+
Invocation.method(
+
#downloadUri,
+
[uri, savePath],
+
{
+
#onReceiveProgress: onReceiveProgress,
+
#cancelToken: cancelToken,
+
#deleteOnError: deleteOnError,
+
#fileAccessMode: fileAccessMode,
+
#lengthHeader: lengthHeader,
+
#data: data,
+
#options: options,
+
},
+
),
+
),
+
),
+
)
+
as _i9.Future<_i6.Response<dynamic>>);
+
+
@override
+
_i9.Future<_i6.Response<T>> request<T>(
+
String? url, {
+
Object? data,
+
Map<String, dynamic>? queryParameters,
+
_i10.CancelToken? cancelToken,
+
_i2.Options? options,
+
_i2.ProgressCallback? onSendProgress,
+
_i2.ProgressCallback? onReceiveProgress,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#request,
+
[url],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#cancelToken: cancelToken,
+
#options: options,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
returnValue: _i9.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#request,
+
[url],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#cancelToken: cancelToken,
+
#options: options,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
),
+
),
+
)
+
as _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i9.Future<_i6.Response<T>> requestUri<T>(
+
Uri? uri, {
+
Object? data,
+
_i10.CancelToken? cancelToken,
+
_i2.Options? options,
+
_i2.ProgressCallback? onSendProgress,
+
_i2.ProgressCallback? onReceiveProgress,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#requestUri,
+
[uri],
+
{
+
#data: data,
+
#cancelToken: cancelToken,
+
#options: options,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
returnValue: _i9.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#requestUri,
+
[uri],
+
{
+
#data: data,
+
#cancelToken: cancelToken,
+
#options: options,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
),
+
),
+
)
+
as _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i9.Future<_i6.Response<T>> fetch<T>(_i2.RequestOptions? requestOptions) =>
+
(super.noSuchMethod(
+
Invocation.method(#fetch, [requestOptions]),
+
returnValue: _i9.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(#fetch, [requestOptions]),
+
),
+
),
+
)
+
as _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i7.Dio clone({
+
_i2.BaseOptions? options,
+
_i3.Interceptors? interceptors,
+
_i4.HttpClientAdapter? httpClientAdapter,
+
_i5.Transformer? transformer,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(#clone, [], {
+
#options: options,
+
#interceptors: interceptors,
+
#httpClientAdapter: httpClientAdapter,
+
#transformer: transformer,
+
}),
+
returnValue: _FakeDio_5(
+
this,
+
Invocation.method(#clone, [], {
+
#options: options,
+
#interceptors: interceptors,
+
#httpClientAdapter: httpClientAdapter,
+
#transformer: transformer,
+
}),
+
),
+
)
+
as _i7.Dio);
+
}
+
+
/// A class which mocks [FlutterSecureStorage].
+
///
+
/// See the documentation for Mockito's code generation for more information.
+
class MockFlutterSecureStorage extends _i1.Mock
+
implements _i8.FlutterSecureStorage {
+
MockFlutterSecureStorage() {
+
_i1.throwOnMissingStub(this);
+
}
+
+
@override
+
_i8.IOSOptions get iOptions =>
+
(super.noSuchMethod(
+
Invocation.getter(#iOptions),
+
returnValue: _FakeIOSOptions_6(this, Invocation.getter(#iOptions)),
+
)
+
as _i8.IOSOptions);
+
+
@override
+
_i8.AndroidOptions get aOptions =>
+
(super.noSuchMethod(
+
Invocation.getter(#aOptions),
+
returnValue: _FakeAndroidOptions_7(
+
this,
+
Invocation.getter(#aOptions),
+
),
+
)
+
as _i8.AndroidOptions);
+
+
@override
+
_i8.LinuxOptions get lOptions =>
+
(super.noSuchMethod(
+
Invocation.getter(#lOptions),
+
returnValue: _FakeLinuxOptions_8(
+
this,
+
Invocation.getter(#lOptions),
+
),
+
)
+
as _i8.LinuxOptions);
+
+
@override
+
_i8.WindowsOptions get wOptions =>
+
(super.noSuchMethod(
+
Invocation.getter(#wOptions),
+
returnValue: _FakeWindowsOptions_9(
+
this,
+
Invocation.getter(#wOptions),
+
),
+
)
+
as _i8.WindowsOptions);
+
+
@override
+
_i8.WebOptions get webOptions =>
+
(super.noSuchMethod(
+
Invocation.getter(#webOptions),
+
returnValue: _FakeWebOptions_10(
+
this,
+
Invocation.getter(#webOptions),
+
),
+
)
+
as _i8.WebOptions);
+
+
@override
+
_i8.MacOsOptions get mOptions =>
+
(super.noSuchMethod(
+
Invocation.getter(#mOptions),
+
returnValue: _FakeMacOsOptions_11(
+
this,
+
Invocation.getter(#mOptions),
+
),
+
)
+
as _i8.MacOsOptions);
+
+
@override
+
void registerListener({
+
required String? key,
+
required _i11.ValueChanged<String?>? listener,
+
}) => super.noSuchMethod(
+
Invocation.method(#registerListener, [], {#key: key, #listener: listener}),
+
returnValueForMissingStub: null,
+
);
+
+
@override
+
void unregisterListener({
+
required String? key,
+
required _i11.ValueChanged<String?>? listener,
+
}) => super.noSuchMethod(
+
Invocation.method(#unregisterListener, [], {
+
#key: key,
+
#listener: listener,
+
}),
+
returnValueForMissingStub: null,
+
);
+
+
@override
+
void unregisterAllListenersForKey({required String? key}) =>
+
super.noSuchMethod(
+
Invocation.method(#unregisterAllListenersForKey, [], {#key: key}),
+
returnValueForMissingStub: null,
+
);
+
+
@override
+
void unregisterAllListeners() => super.noSuchMethod(
+
Invocation.method(#unregisterAllListeners, []),
+
returnValueForMissingStub: null,
+
);
+
+
@override
+
_i9.Future<void> write({
+
required String? key,
+
required String? value,
+
_i8.IOSOptions? iOptions,
+
_i8.AndroidOptions? aOptions,
+
_i8.LinuxOptions? lOptions,
+
_i8.WebOptions? webOptions,
+
_i8.MacOsOptions? mOptions,
+
_i8.WindowsOptions? wOptions,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(#write, [], {
+
#key: key,
+
#value: value,
+
#iOptions: iOptions,
+
#aOptions: aOptions,
+
#lOptions: lOptions,
+
#webOptions: webOptions,
+
#mOptions: mOptions,
+
#wOptions: wOptions,
+
}),
+
returnValue: _i9.Future<void>.value(),
+
returnValueForMissingStub: _i9.Future<void>.value(),
+
)
+
as _i9.Future<void>);
+
+
@override
+
_i9.Future<String?> read({
+
required String? key,
+
_i8.IOSOptions? iOptions,
+
_i8.AndroidOptions? aOptions,
+
_i8.LinuxOptions? lOptions,
+
_i8.WebOptions? webOptions,
+
_i8.MacOsOptions? mOptions,
+
_i8.WindowsOptions? wOptions,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(#read, [], {
+
#key: key,
+
#iOptions: iOptions,
+
#aOptions: aOptions,
+
#lOptions: lOptions,
+
#webOptions: webOptions,
+
#mOptions: mOptions,
+
#wOptions: wOptions,
+
}),
+
returnValue: _i9.Future<String?>.value(),
+
)
+
as _i9.Future<String?>);
+
+
@override
+
_i9.Future<bool> containsKey({
+
required String? key,
+
_i8.IOSOptions? iOptions,
+
_i8.AndroidOptions? aOptions,
+
_i8.LinuxOptions? lOptions,
+
_i8.WebOptions? webOptions,
+
_i8.MacOsOptions? mOptions,
+
_i8.WindowsOptions? wOptions,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(#containsKey, [], {
+
#key: key,
+
#iOptions: iOptions,
+
#aOptions: aOptions,
+
#lOptions: lOptions,
+
#webOptions: webOptions,
+
#mOptions: mOptions,
+
#wOptions: wOptions,
+
}),
+
returnValue: _i9.Future<bool>.value(false),
+
)
+
as _i9.Future<bool>);
+
+
@override
+
_i9.Future<void> delete({
+
required String? key,
+
_i8.IOSOptions? iOptions,
+
_i8.AndroidOptions? aOptions,
+
_i8.LinuxOptions? lOptions,
+
_i8.WebOptions? webOptions,
+
_i8.MacOsOptions? mOptions,
+
_i8.WindowsOptions? wOptions,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(#delete, [], {
+
#key: key,
+
#iOptions: iOptions,
+
#aOptions: aOptions,
+
#lOptions: lOptions,
+
#webOptions: webOptions,
+
#mOptions: mOptions,
+
#wOptions: wOptions,
+
}),
+
returnValue: _i9.Future<void>.value(),
+
returnValueForMissingStub: _i9.Future<void>.value(),
+
)
+
as _i9.Future<void>);
+
+
@override
+
_i9.Future<Map<String, String>> readAll({
+
_i8.IOSOptions? iOptions,
+
_i8.AndroidOptions? aOptions,
+
_i8.LinuxOptions? lOptions,
+
_i8.WebOptions? webOptions,
+
_i8.MacOsOptions? mOptions,
+
_i8.WindowsOptions? wOptions,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(#readAll, [], {
+
#iOptions: iOptions,
+
#aOptions: aOptions,
+
#lOptions: lOptions,
+
#webOptions: webOptions,
+
#mOptions: mOptions,
+
#wOptions: wOptions,
+
}),
+
returnValue: _i9.Future<Map<String, String>>.value(
+
<String, String>{},
+
),
+
)
+
as _i9.Future<Map<String, String>>);
+
+
@override
+
_i9.Future<void> deleteAll({
+
_i8.IOSOptions? iOptions,
+
_i8.AndroidOptions? aOptions,
+
_i8.LinuxOptions? lOptions,
+
_i8.WebOptions? webOptions,
+
_i8.MacOsOptions? mOptions,
+
_i8.WindowsOptions? wOptions,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(#deleteAll, [], {
+
#iOptions: iOptions,
+
#aOptions: aOptions,
+
#lOptions: lOptions,
+
#webOptions: webOptions,
+
#mOptions: mOptions,
+
#wOptions: wOptions,
+
}),
+
returnValue: _i9.Future<void>.value(),
+
returnValueForMissingStub: _i9.Future<void>.value(),
+
)
+
as _i9.Future<void>);
+
+
@override
+
_i9.Future<bool?> isCupertinoProtectedDataAvailable() =>
+
(super.noSuchMethod(
+
Invocation.method(#isCupertinoProtectedDataAvailable, []),
+
returnValue: _i9.Future<bool?>.value(),
+
)
+
as _i9.Future<bool?>);
+
}
+1102
test/services/coves_auth_service_validation_test.mocks.dart
···
+
// Mocks generated by Mockito 5.4.6 from annotations
+
// in coves_flutter/test/services/coves_auth_service_validation_test.dart.
+
// Do not manually edit this file.
+
+
// ignore_for_file: no_leading_underscores_for_library_prefixes
+
import 'dart:async' as _i9;
+
+
import 'package:dio/src/adapter.dart' as _i4;
+
import 'package:dio/src/cancel_token.dart' as _i10;
+
import 'package:dio/src/dio.dart' as _i7;
+
import 'package:dio/src/dio_mixin.dart' as _i3;
+
import 'package:dio/src/options.dart' as _i2;
+
import 'package:dio/src/response.dart' as _i6;
+
import 'package:dio/src/transformer.dart' as _i5;
+
import 'package:flutter/foundation.dart' as _i11;
+
import 'package:flutter_secure_storage/flutter_secure_storage.dart' as _i8;
+
import 'package:mockito/mockito.dart' as _i1;
+
+
// ignore_for_file: type=lint
+
// ignore_for_file: avoid_redundant_argument_values
+
// ignore_for_file: avoid_setters_without_getters
+
// ignore_for_file: comment_references
+
// ignore_for_file: deprecated_member_use
+
// ignore_for_file: deprecated_member_use_from_same_package
+
// ignore_for_file: implementation_imports
+
// ignore_for_file: invalid_use_of_visible_for_testing_member
+
// ignore_for_file: must_be_immutable
+
// ignore_for_file: prefer_const_constructors
+
// ignore_for_file: unnecessary_parenthesis
+
// ignore_for_file: camel_case_types
+
// ignore_for_file: subtype_of_sealed_class
+
// ignore_for_file: invalid_use_of_internal_member
+
+
class _FakeBaseOptions_0 extends _i1.SmartFake implements _i2.BaseOptions {
+
_FakeBaseOptions_0(Object parent, Invocation parentInvocation)
+
: super(parent, parentInvocation);
+
}
+
+
class _FakeInterceptors_1 extends _i1.SmartFake implements _i3.Interceptors {
+
_FakeInterceptors_1(Object parent, Invocation parentInvocation)
+
: super(parent, parentInvocation);
+
}
+
+
class _FakeHttpClientAdapter_2 extends _i1.SmartFake
+
implements _i4.HttpClientAdapter {
+
_FakeHttpClientAdapter_2(Object parent, Invocation parentInvocation)
+
: super(parent, parentInvocation);
+
}
+
+
class _FakeTransformer_3 extends _i1.SmartFake implements _i5.Transformer {
+
_FakeTransformer_3(Object parent, Invocation parentInvocation)
+
: super(parent, parentInvocation);
+
}
+
+
class _FakeResponse_4<T1> extends _i1.SmartFake implements _i6.Response<T1> {
+
_FakeResponse_4(Object parent, Invocation parentInvocation)
+
: super(parent, parentInvocation);
+
}
+
+
class _FakeDio_5 extends _i1.SmartFake implements _i7.Dio {
+
_FakeDio_5(Object parent, Invocation parentInvocation)
+
: super(parent, parentInvocation);
+
}
+
+
class _FakeIOSOptions_6 extends _i1.SmartFake implements _i8.IOSOptions {
+
_FakeIOSOptions_6(Object parent, Invocation parentInvocation)
+
: super(parent, parentInvocation);
+
}
+
+
class _FakeAndroidOptions_7 extends _i1.SmartFake
+
implements _i8.AndroidOptions {
+
_FakeAndroidOptions_7(Object parent, Invocation parentInvocation)
+
: super(parent, parentInvocation);
+
}
+
+
class _FakeLinuxOptions_8 extends _i1.SmartFake implements _i8.LinuxOptions {
+
_FakeLinuxOptions_8(Object parent, Invocation parentInvocation)
+
: super(parent, parentInvocation);
+
}
+
+
class _FakeWindowsOptions_9 extends _i1.SmartFake
+
implements _i8.WindowsOptions {
+
_FakeWindowsOptions_9(Object parent, Invocation parentInvocation)
+
: super(parent, parentInvocation);
+
}
+
+
class _FakeWebOptions_10 extends _i1.SmartFake implements _i8.WebOptions {
+
_FakeWebOptions_10(Object parent, Invocation parentInvocation)
+
: super(parent, parentInvocation);
+
}
+
+
class _FakeMacOsOptions_11 extends _i1.SmartFake implements _i8.MacOsOptions {
+
_FakeMacOsOptions_11(Object parent, Invocation parentInvocation)
+
: super(parent, parentInvocation);
+
}
+
+
/// A class which mocks [Dio].
+
///
+
/// See the documentation for Mockito's code generation for more information.
+
class MockDio extends _i1.Mock implements _i7.Dio {
+
MockDio() {
+
_i1.throwOnMissingStub(this);
+
}
+
+
@override
+
_i2.BaseOptions get options =>
+
(super.noSuchMethod(
+
Invocation.getter(#options),
+
returnValue: _FakeBaseOptions_0(this, Invocation.getter(#options)),
+
)
+
as _i2.BaseOptions);
+
+
@override
+
_i3.Interceptors get interceptors =>
+
(super.noSuchMethod(
+
Invocation.getter(#interceptors),
+
returnValue: _FakeInterceptors_1(
+
this,
+
Invocation.getter(#interceptors),
+
),
+
)
+
as _i3.Interceptors);
+
+
@override
+
_i4.HttpClientAdapter get httpClientAdapter =>
+
(super.noSuchMethod(
+
Invocation.getter(#httpClientAdapter),
+
returnValue: _FakeHttpClientAdapter_2(
+
this,
+
Invocation.getter(#httpClientAdapter),
+
),
+
)
+
as _i4.HttpClientAdapter);
+
+
@override
+
_i5.Transformer get transformer =>
+
(super.noSuchMethod(
+
Invocation.getter(#transformer),
+
returnValue: _FakeTransformer_3(
+
this,
+
Invocation.getter(#transformer),
+
),
+
)
+
as _i5.Transformer);
+
+
@override
+
set options(_i2.BaseOptions? value) => super.noSuchMethod(
+
Invocation.setter(#options, value),
+
returnValueForMissingStub: null,
+
);
+
+
@override
+
set httpClientAdapter(_i4.HttpClientAdapter? value) => super.noSuchMethod(
+
Invocation.setter(#httpClientAdapter, value),
+
returnValueForMissingStub: null,
+
);
+
+
@override
+
set transformer(_i5.Transformer? value) => super.noSuchMethod(
+
Invocation.setter(#transformer, value),
+
returnValueForMissingStub: null,
+
);
+
+
@override
+
void close({bool? force = false}) => super.noSuchMethod(
+
Invocation.method(#close, [], {#force: force}),
+
returnValueForMissingStub: null,
+
);
+
+
@override
+
_i9.Future<_i6.Response<T>> head<T>(
+
String? path, {
+
Object? data,
+
Map<String, dynamic>? queryParameters,
+
_i2.Options? options,
+
_i10.CancelToken? cancelToken,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#head,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
},
+
),
+
returnValue: _i9.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#head,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
},
+
),
+
),
+
),
+
)
+
as _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i9.Future<_i6.Response<T>> headUri<T>(
+
Uri? uri, {
+
Object? data,
+
_i2.Options? options,
+
_i10.CancelToken? cancelToken,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#headUri,
+
[uri],
+
{#data: data, #options: options, #cancelToken: cancelToken},
+
),
+
returnValue: _i9.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#headUri,
+
[uri],
+
{#data: data, #options: options, #cancelToken: cancelToken},
+
),
+
),
+
),
+
)
+
as _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i9.Future<_i6.Response<T>> get<T>(
+
String? path, {
+
Object? data,
+
Map<String, dynamic>? queryParameters,
+
_i2.Options? options,
+
_i10.CancelToken? cancelToken,
+
_i2.ProgressCallback? onReceiveProgress,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#get,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
returnValue: _i9.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#get,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
),
+
),
+
)
+
as _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i9.Future<_i6.Response<T>> getUri<T>(
+
Uri? uri, {
+
Object? data,
+
_i2.Options? options,
+
_i10.CancelToken? cancelToken,
+
_i2.ProgressCallback? onReceiveProgress,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#getUri,
+
[uri],
+
{
+
#data: data,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
returnValue: _i9.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#getUri,
+
[uri],
+
{
+
#data: data,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
),
+
),
+
)
+
as _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i9.Future<_i6.Response<T>> post<T>(
+
String? path, {
+
Object? data,
+
Map<String, dynamic>? queryParameters,
+
_i2.Options? options,
+
_i10.CancelToken? cancelToken,
+
_i2.ProgressCallback? onSendProgress,
+
_i2.ProgressCallback? onReceiveProgress,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#post,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
returnValue: _i9.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#post,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
),
+
),
+
)
+
as _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i9.Future<_i6.Response<T>> postUri<T>(
+
Uri? uri, {
+
Object? data,
+
_i2.Options? options,
+
_i10.CancelToken? cancelToken,
+
_i2.ProgressCallback? onSendProgress,
+
_i2.ProgressCallback? onReceiveProgress,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#postUri,
+
[uri],
+
{
+
#data: data,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
returnValue: _i9.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#postUri,
+
[uri],
+
{
+
#data: data,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
),
+
),
+
)
+
as _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i9.Future<_i6.Response<T>> put<T>(
+
String? path, {
+
Object? data,
+
Map<String, dynamic>? queryParameters,
+
_i2.Options? options,
+
_i10.CancelToken? cancelToken,
+
_i2.ProgressCallback? onSendProgress,
+
_i2.ProgressCallback? onReceiveProgress,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#put,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
returnValue: _i9.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#put,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
),
+
),
+
)
+
as _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i9.Future<_i6.Response<T>> putUri<T>(
+
Uri? uri, {
+
Object? data,
+
_i2.Options? options,
+
_i10.CancelToken? cancelToken,
+
_i2.ProgressCallback? onSendProgress,
+
_i2.ProgressCallback? onReceiveProgress,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#putUri,
+
[uri],
+
{
+
#data: data,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
returnValue: _i9.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#putUri,
+
[uri],
+
{
+
#data: data,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
),
+
),
+
)
+
as _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i9.Future<_i6.Response<T>> patch<T>(
+
String? path, {
+
Object? data,
+
Map<String, dynamic>? queryParameters,
+
_i2.Options? options,
+
_i10.CancelToken? cancelToken,
+
_i2.ProgressCallback? onSendProgress,
+
_i2.ProgressCallback? onReceiveProgress,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#patch,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
returnValue: _i9.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#patch,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
),
+
),
+
)
+
as _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i9.Future<_i6.Response<T>> patchUri<T>(
+
Uri? uri, {
+
Object? data,
+
_i2.Options? options,
+
_i10.CancelToken? cancelToken,
+
_i2.ProgressCallback? onSendProgress,
+
_i2.ProgressCallback? onReceiveProgress,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#patchUri,
+
[uri],
+
{
+
#data: data,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
returnValue: _i9.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#patchUri,
+
[uri],
+
{
+
#data: data,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
),
+
),
+
)
+
as _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i9.Future<_i6.Response<T>> delete<T>(
+
String? path, {
+
Object? data,
+
Map<String, dynamic>? queryParameters,
+
_i2.Options? options,
+
_i10.CancelToken? cancelToken,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#delete,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
},
+
),
+
returnValue: _i9.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#delete,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
},
+
),
+
),
+
),
+
)
+
as _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i9.Future<_i6.Response<T>> deleteUri<T>(
+
Uri? uri, {
+
Object? data,
+
_i2.Options? options,
+
_i10.CancelToken? cancelToken,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#deleteUri,
+
[uri],
+
{#data: data, #options: options, #cancelToken: cancelToken},
+
),
+
returnValue: _i9.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#deleteUri,
+
[uri],
+
{#data: data, #options: options, #cancelToken: cancelToken},
+
),
+
),
+
),
+
)
+
as _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i9.Future<_i6.Response<dynamic>> download(
+
String? urlPath,
+
dynamic savePath, {
+
_i2.ProgressCallback? onReceiveProgress,
+
Map<String, dynamic>? queryParameters,
+
_i10.CancelToken? cancelToken,
+
bool? deleteOnError = true,
+
_i2.FileAccessMode? fileAccessMode = _i2.FileAccessMode.write,
+
String? lengthHeader = 'content-length',
+
Object? data,
+
_i2.Options? options,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#download,
+
[urlPath, savePath],
+
{
+
#onReceiveProgress: onReceiveProgress,
+
#queryParameters: queryParameters,
+
#cancelToken: cancelToken,
+
#deleteOnError: deleteOnError,
+
#fileAccessMode: fileAccessMode,
+
#lengthHeader: lengthHeader,
+
#data: data,
+
#options: options,
+
},
+
),
+
returnValue: _i9.Future<_i6.Response<dynamic>>.value(
+
_FakeResponse_4<dynamic>(
+
this,
+
Invocation.method(
+
#download,
+
[urlPath, savePath],
+
{
+
#onReceiveProgress: onReceiveProgress,
+
#queryParameters: queryParameters,
+
#cancelToken: cancelToken,
+
#deleteOnError: deleteOnError,
+
#fileAccessMode: fileAccessMode,
+
#lengthHeader: lengthHeader,
+
#data: data,
+
#options: options,
+
},
+
),
+
),
+
),
+
)
+
as _i9.Future<_i6.Response<dynamic>>);
+
+
@override
+
_i9.Future<_i6.Response<dynamic>> downloadUri(
+
Uri? uri,
+
dynamic savePath, {
+
_i2.ProgressCallback? onReceiveProgress,
+
_i10.CancelToken? cancelToken,
+
bool? deleteOnError = true,
+
_i2.FileAccessMode? fileAccessMode = _i2.FileAccessMode.write,
+
String? lengthHeader = 'content-length',
+
Object? data,
+
_i2.Options? options,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#downloadUri,
+
[uri, savePath],
+
{
+
#onReceiveProgress: onReceiveProgress,
+
#cancelToken: cancelToken,
+
#deleteOnError: deleteOnError,
+
#fileAccessMode: fileAccessMode,
+
#lengthHeader: lengthHeader,
+
#data: data,
+
#options: options,
+
},
+
),
+
returnValue: _i9.Future<_i6.Response<dynamic>>.value(
+
_FakeResponse_4<dynamic>(
+
this,
+
Invocation.method(
+
#downloadUri,
+
[uri, savePath],
+
{
+
#onReceiveProgress: onReceiveProgress,
+
#cancelToken: cancelToken,
+
#deleteOnError: deleteOnError,
+
#fileAccessMode: fileAccessMode,
+
#lengthHeader: lengthHeader,
+
#data: data,
+
#options: options,
+
},
+
),
+
),
+
),
+
)
+
as _i9.Future<_i6.Response<dynamic>>);
+
+
@override
+
_i9.Future<_i6.Response<T>> request<T>(
+
String? url, {
+
Object? data,
+
Map<String, dynamic>? queryParameters,
+
_i10.CancelToken? cancelToken,
+
_i2.Options? options,
+
_i2.ProgressCallback? onSendProgress,
+
_i2.ProgressCallback? onReceiveProgress,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#request,
+
[url],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#cancelToken: cancelToken,
+
#options: options,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
returnValue: _i9.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#request,
+
[url],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#cancelToken: cancelToken,
+
#options: options,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
),
+
),
+
)
+
as _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i9.Future<_i6.Response<T>> requestUri<T>(
+
Uri? uri, {
+
Object? data,
+
_i10.CancelToken? cancelToken,
+
_i2.Options? options,
+
_i2.ProgressCallback? onSendProgress,
+
_i2.ProgressCallback? onReceiveProgress,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#requestUri,
+
[uri],
+
{
+
#data: data,
+
#cancelToken: cancelToken,
+
#options: options,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
returnValue: _i9.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#requestUri,
+
[uri],
+
{
+
#data: data,
+
#cancelToken: cancelToken,
+
#options: options,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
),
+
),
+
)
+
as _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i9.Future<_i6.Response<T>> fetch<T>(_i2.RequestOptions? requestOptions) =>
+
(super.noSuchMethod(
+
Invocation.method(#fetch, [requestOptions]),
+
returnValue: _i9.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(#fetch, [requestOptions]),
+
),
+
),
+
)
+
as _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i7.Dio clone({
+
_i2.BaseOptions? options,
+
_i3.Interceptors? interceptors,
+
_i4.HttpClientAdapter? httpClientAdapter,
+
_i5.Transformer? transformer,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(#clone, [], {
+
#options: options,
+
#interceptors: interceptors,
+
#httpClientAdapter: httpClientAdapter,
+
#transformer: transformer,
+
}),
+
returnValue: _FakeDio_5(
+
this,
+
Invocation.method(#clone, [], {
+
#options: options,
+
#interceptors: interceptors,
+
#httpClientAdapter: httpClientAdapter,
+
#transformer: transformer,
+
}),
+
),
+
)
+
as _i7.Dio);
+
}
+
+
/// A class which mocks [FlutterSecureStorage].
+
///
+
/// See the documentation for Mockito's code generation for more information.
+
class MockFlutterSecureStorage extends _i1.Mock
+
implements _i8.FlutterSecureStorage {
+
MockFlutterSecureStorage() {
+
_i1.throwOnMissingStub(this);
+
}
+
+
@override
+
_i8.IOSOptions get iOptions =>
+
(super.noSuchMethod(
+
Invocation.getter(#iOptions),
+
returnValue: _FakeIOSOptions_6(this, Invocation.getter(#iOptions)),
+
)
+
as _i8.IOSOptions);
+
+
@override
+
_i8.AndroidOptions get aOptions =>
+
(super.noSuchMethod(
+
Invocation.getter(#aOptions),
+
returnValue: _FakeAndroidOptions_7(
+
this,
+
Invocation.getter(#aOptions),
+
),
+
)
+
as _i8.AndroidOptions);
+
+
@override
+
_i8.LinuxOptions get lOptions =>
+
(super.noSuchMethod(
+
Invocation.getter(#lOptions),
+
returnValue: _FakeLinuxOptions_8(
+
this,
+
Invocation.getter(#lOptions),
+
),
+
)
+
as _i8.LinuxOptions);
+
+
@override
+
_i8.WindowsOptions get wOptions =>
+
(super.noSuchMethod(
+
Invocation.getter(#wOptions),
+
returnValue: _FakeWindowsOptions_9(
+
this,
+
Invocation.getter(#wOptions),
+
),
+
)
+
as _i8.WindowsOptions);
+
+
@override
+
_i8.WebOptions get webOptions =>
+
(super.noSuchMethod(
+
Invocation.getter(#webOptions),
+
returnValue: _FakeWebOptions_10(
+
this,
+
Invocation.getter(#webOptions),
+
),
+
)
+
as _i8.WebOptions);
+
+
@override
+
_i8.MacOsOptions get mOptions =>
+
(super.noSuchMethod(
+
Invocation.getter(#mOptions),
+
returnValue: _FakeMacOsOptions_11(
+
this,
+
Invocation.getter(#mOptions),
+
),
+
)
+
as _i8.MacOsOptions);
+
+
@override
+
void registerListener({
+
required String? key,
+
required _i11.ValueChanged<String?>? listener,
+
}) => super.noSuchMethod(
+
Invocation.method(#registerListener, [], {#key: key, #listener: listener}),
+
returnValueForMissingStub: null,
+
);
+
+
@override
+
void unregisterListener({
+
required String? key,
+
required _i11.ValueChanged<String?>? listener,
+
}) => super.noSuchMethod(
+
Invocation.method(#unregisterListener, [], {
+
#key: key,
+
#listener: listener,
+
}),
+
returnValueForMissingStub: null,
+
);
+
+
@override
+
void unregisterAllListenersForKey({required String? key}) =>
+
super.noSuchMethod(
+
Invocation.method(#unregisterAllListenersForKey, [], {#key: key}),
+
returnValueForMissingStub: null,
+
);
+
+
@override
+
void unregisterAllListeners() => super.noSuchMethod(
+
Invocation.method(#unregisterAllListeners, []),
+
returnValueForMissingStub: null,
+
);
+
+
@override
+
_i9.Future<void> write({
+
required String? key,
+
required String? value,
+
_i8.IOSOptions? iOptions,
+
_i8.AndroidOptions? aOptions,
+
_i8.LinuxOptions? lOptions,
+
_i8.WebOptions? webOptions,
+
_i8.MacOsOptions? mOptions,
+
_i8.WindowsOptions? wOptions,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(#write, [], {
+
#key: key,
+
#value: value,
+
#iOptions: iOptions,
+
#aOptions: aOptions,
+
#lOptions: lOptions,
+
#webOptions: webOptions,
+
#mOptions: mOptions,
+
#wOptions: wOptions,
+
}),
+
returnValue: _i9.Future<void>.value(),
+
returnValueForMissingStub: _i9.Future<void>.value(),
+
)
+
as _i9.Future<void>);
+
+
@override
+
_i9.Future<String?> read({
+
required String? key,
+
_i8.IOSOptions? iOptions,
+
_i8.AndroidOptions? aOptions,
+
_i8.LinuxOptions? lOptions,
+
_i8.WebOptions? webOptions,
+
_i8.MacOsOptions? mOptions,
+
_i8.WindowsOptions? wOptions,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(#read, [], {
+
#key: key,
+
#iOptions: iOptions,
+
#aOptions: aOptions,
+
#lOptions: lOptions,
+
#webOptions: webOptions,
+
#mOptions: mOptions,
+
#wOptions: wOptions,
+
}),
+
returnValue: _i9.Future<String?>.value(),
+
)
+
as _i9.Future<String?>);
+
+
@override
+
_i9.Future<bool> containsKey({
+
required String? key,
+
_i8.IOSOptions? iOptions,
+
_i8.AndroidOptions? aOptions,
+
_i8.LinuxOptions? lOptions,
+
_i8.WebOptions? webOptions,
+
_i8.MacOsOptions? mOptions,
+
_i8.WindowsOptions? wOptions,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(#containsKey, [], {
+
#key: key,
+
#iOptions: iOptions,
+
#aOptions: aOptions,
+
#lOptions: lOptions,
+
#webOptions: webOptions,
+
#mOptions: mOptions,
+
#wOptions: wOptions,
+
}),
+
returnValue: _i9.Future<bool>.value(false),
+
)
+
as _i9.Future<bool>);
+
+
@override
+
_i9.Future<void> delete({
+
required String? key,
+
_i8.IOSOptions? iOptions,
+
_i8.AndroidOptions? aOptions,
+
_i8.LinuxOptions? lOptions,
+
_i8.WebOptions? webOptions,
+
_i8.MacOsOptions? mOptions,
+
_i8.WindowsOptions? wOptions,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(#delete, [], {
+
#key: key,
+
#iOptions: iOptions,
+
#aOptions: aOptions,
+
#lOptions: lOptions,
+
#webOptions: webOptions,
+
#mOptions: mOptions,
+
#wOptions: wOptions,
+
}),
+
returnValue: _i9.Future<void>.value(),
+
returnValueForMissingStub: _i9.Future<void>.value(),
+
)
+
as _i9.Future<void>);
+
+
@override
+
_i9.Future<Map<String, String>> readAll({
+
_i8.IOSOptions? iOptions,
+
_i8.AndroidOptions? aOptions,
+
_i8.LinuxOptions? lOptions,
+
_i8.WebOptions? webOptions,
+
_i8.MacOsOptions? mOptions,
+
_i8.WindowsOptions? wOptions,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(#readAll, [], {
+
#iOptions: iOptions,
+
#aOptions: aOptions,
+
#lOptions: lOptions,
+
#webOptions: webOptions,
+
#mOptions: mOptions,
+
#wOptions: wOptions,
+
}),
+
returnValue: _i9.Future<Map<String, String>>.value(
+
<String, String>{},
+
),
+
)
+
as _i9.Future<Map<String, String>>);
+
+
@override
+
_i9.Future<void> deleteAll({
+
_i8.IOSOptions? iOptions,
+
_i8.AndroidOptions? aOptions,
+
_i8.LinuxOptions? lOptions,
+
_i8.WebOptions? webOptions,
+
_i8.MacOsOptions? mOptions,
+
_i8.WindowsOptions? wOptions,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(#deleteAll, [], {
+
#iOptions: iOptions,
+
#aOptions: aOptions,
+
#lOptions: lOptions,
+
#webOptions: webOptions,
+
#mOptions: mOptions,
+
#wOptions: wOptions,
+
}),
+
returnValue: _i9.Future<void>.value(),
+
returnValueForMissingStub: _i9.Future<void>.value(),
+
)
+
as _i9.Future<void>);
+
+
@override
+
_i9.Future<bool?> isCupertinoProtectedDataAvailable() =>
+
(super.noSuchMethod(
+
Invocation.method(#isCupertinoProtectedDataAvailable, []),
+
returnValue: _i9.Future<bool?>.value(),
+
)
+
as _i9.Future<bool?>);
+
}
+14 -42
test/services/vote_service_token_refresh_test.dart
···
expect(signOutCallCount, 0);
});
-
test('should handle 401 on vote delete and retry', () async {
-
const rkey = 'abc123';
-
-
// Mock will always return 401
-
dioAdapter.onPost(
-
'/xrpc/social.coves.feed.vote.delete',
-
(server) => server.reply(401, {
-
'error': 'Unauthorized',
-
'message': 'Token expired',
-
}),
-
data: {'rkey': rkey},
-
);
-
-
// Create vote with existing vote (will trigger delete)
-
expect(
-
() => voteService.createVote(
-
postUri: 'at://did:plc:test/social.coves.post.record/123',
-
postCid: 'bafy123',
-
direction: 'up',
-
existingVoteRkey: rkey,
-
existingVoteDirection: 'up',
-
),
-
throwsA(isA<Exception>()),
-
);
-
-
// Wait for async operations
-
await Future.delayed(const Duration(milliseconds: 100));
-
-
// Verify token refresh was called
-
expect(tokenRefreshCallCount, 1);
-
-
// Verify user was signed out after retry failed
-
expect(signOutCallCount, 1);
-
});
+
// Note: delete method was removed - backend handles toggle via create endpoint
test('should throw ApiException when session is null', () async {
// Create service that returns null session
···
sessionId: 'session123',
);
-
// Second request should use the new token
+
// Second request uses a different post
+
const postUri2 = 'at://did:plc:test/social.coves.post.record/456';
dioAdapter.onPost(
-
'/xrpc/social.coves.feed.vote.delete',
-
(server) => server.reply(200, {}),
-
data: {'rkey': 'xyz'},
+
'/xrpc/social.coves.feed.vote.create',
+
(server) => server.reply(200, {
+
'uri': 'at://did:plc:test/social.coves.feed.vote/abc',
+
'cid': 'bafy789',
+
}),
+
data: {
+
'subject': {'uri': postUri2, 'cid': postCid},
+
'direction': 'up',
+
},
);
-
// Make second request (delete vote)
+
// Make second request
await voteService.createVote(
-
postUri: postUri,
+
postUri: postUri2,
postCid: postCid,
direction: 'up',
-
existingVoteRkey: 'xyz',
-
existingVoteDirection: 'up',
);
// Verify no refresh was needed (tokens were valid)
+6
lib/services/api_exceptions.dart
···
FederationException(super.message, {super.originalError})
: super(statusCode: null);
}
+
+
/// Validation error
+
/// Client-side validation failure (empty content, exceeds limits, etc.)
+
class ValidationException extends ApiException {
+
ValidationException(super.message) : super(statusCode: null);
+
}
+170
lib/services/comment_service.dart
···
+
import 'package:dio/dio.dart';
+
import 'package:flutter/foundation.dart';
+
+
import '../config/environment_config.dart';
+
import '../models/coves_session.dart';
+
import 'api_exceptions.dart';
+
import 'auth_interceptor.dart';
+
+
/// Comment Service
+
///
+
/// Handles comment creation through the Coves backend.
+
///
+
/// **Architecture with Backend OAuth**:
+
/// With sealed tokens, the client cannot write directly to the user's PDS
+
/// (no DPoP keys available). Instead, comments go through the Coves backend:
+
///
+
/// Mobile Client โ†’ Coves Backend (sealed token) โ†’ User's PDS (DPoP)
+
///
+
/// The backend:
+
/// 1. Unseals the token to get the actual access/refresh tokens
+
/// 2. Uses stored DPoP keys to sign requests
+
/// 3. Writes to the user's PDS on their behalf
+
///
+
/// **Backend Endpoint**:
+
/// - POST /xrpc/social.coves.community.comment.create
+
class CommentService {
+
CommentService({
+
Future<CovesSession?> Function()? sessionGetter,
+
Future<bool> Function()? tokenRefresher,
+
Future<void> Function()? signOutHandler,
+
Dio? dio,
+
}) : _sessionGetter = sessionGetter {
+
_dio =
+
dio ??
+
Dio(
+
BaseOptions(
+
baseUrl: EnvironmentConfig.current.apiUrl,
+
connectTimeout: const Duration(seconds: 30),
+
receiveTimeout: const Duration(seconds: 30),
+
headers: {'Content-Type': 'application/json'},
+
),
+
);
+
+
// Add shared 401 retry interceptor
+
_dio.interceptors.add(
+
createAuthInterceptor(
+
sessionGetter: sessionGetter,
+
tokenRefresher: tokenRefresher,
+
signOutHandler: signOutHandler,
+
serviceName: 'CommentService',
+
dio: _dio,
+
),
+
);
+
}
+
+
final Future<CovesSession?> Function()? _sessionGetter;
+
late final Dio _dio;
+
+
/// Create a comment
+
///
+
/// Sends comment request to the Coves backend, which writes to the
+
/// user's PDS.
+
///
+
/// Parameters:
+
/// - [rootUri]: AT-URI of the root post (always the original post)
+
/// - [rootCid]: CID of the root post
+
/// - [parentUri]: AT-URI of the parent (post or comment)
+
/// - [parentCid]: CID of the parent
+
/// - [content]: Comment text content
+
///
+
/// Returns:
+
/// - CreateCommentResponse with uri and cid of the created comment
+
///
+
/// Throws:
+
/// - ApiException for API errors
+
/// - AuthenticationException for auth failures
+
Future<CreateCommentResponse> createComment({
+
required String rootUri,
+
required String rootCid,
+
required String parentUri,
+
required String parentCid,
+
required String content,
+
}) async {
+
try {
+
final session = await _sessionGetter?.call();
+
+
if (session == null) {
+
throw AuthenticationException(
+
'User not authenticated - no session available',
+
);
+
}
+
+
if (kDebugMode) {
+
debugPrint('๐Ÿ’ฌ Creating comment via backend');
+
debugPrint(' Root: $rootUri');
+
debugPrint(' Parent: $parentUri');
+
debugPrint(' Content length: ${content.length}');
+
}
+
+
// Send comment request to backend
+
// Note: Authorization header is added by the interceptor
+
final response = await _dio.post<Map<String, dynamic>>(
+
'/xrpc/social.coves.community.comment.create',
+
data: {
+
'reply': {
+
'root': {'uri': rootUri, 'cid': rootCid},
+
'parent': {'uri': parentUri, 'cid': parentCid},
+
},
+
'content': content,
+
},
+
);
+
+
final data = response.data;
+
if (data == null) {
+
throw ApiException('Invalid response from server - no data');
+
}
+
+
final uri = data['uri'] as String?;
+
final cid = data['cid'] as String?;
+
+
if (uri == null || uri.isEmpty || cid == null || cid.isEmpty) {
+
throw ApiException(
+
'Invalid response from server - missing uri or cid',
+
);
+
}
+
+
if (kDebugMode) {
+
debugPrint('โœ… Comment created: $uri');
+
}
+
+
return CreateCommentResponse(uri: uri, cid: cid);
+
} on DioException catch (e) {
+
if (kDebugMode) {
+
debugPrint('โŒ Comment creation failed: ${e.message}');
+
debugPrint(' Status: ${e.response?.statusCode}');
+
debugPrint(' Data: ${e.response?.data}');
+
}
+
+
if (e.response?.statusCode == 401) {
+
throw AuthenticationException(
+
'Authentication failed. Please sign in again.',
+
originalError: e,
+
);
+
}
+
+
throw ApiException(
+
'Failed to create comment: ${e.message}',
+
statusCode: e.response?.statusCode,
+
originalError: e,
+
);
+
} on AuthenticationException {
+
rethrow;
+
} on ApiException {
+
rethrow;
+
} on Exception catch (e) {
+
throw ApiException('Failed to create comment: $e');
+
}
+
}
+
}
+
+
/// Response from comment creation
+
class CreateCommentResponse {
+
const CreateCommentResponse({required this.uri, required this.cid});
+
+
/// AT-URI of the created comment record
+
final String uri;
+
+
/// CID of the created comment record
+
final String cid;
+
}
+1
pubspec.yaml
···
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.8
+
characters: ^1.4.0 # Unicode grapheme cluster support for emoji counting
flutter_secure_storage: ^9.2.2
shared_preferences: ^2.3.3
go_router: ^16.3.0
+153
lib/services/auth_interceptor.dart
···
+
import 'package:dio/dio.dart';
+
import 'package:flutter/foundation.dart';
+
+
import '../models/coves_session.dart';
+
+
/// Creates a Dio interceptor that handles authentication and automatic
+
/// token refresh on 401 errors.
+
///
+
/// This shared utility eliminates duplication between VoteService and
+
/// CommentService by providing a single implementation of:
+
/// - Adding Authorization headers with fresh tokens on each request
+
/// - Automatic retry with token refresh on 401 responses
+
/// - Sign-out handling when refresh fails
+
///
+
/// Usage:
+
/// ```dart
+
/// _dio.interceptors.add(
+
/// createAuthInterceptor(
+
/// sessionGetter: () async => authProvider.session,
+
/// tokenRefresher: authProvider.refreshToken,
+
/// signOutHandler: authProvider.signOut,
+
/// serviceName: 'MyService',
+
/// ),
+
/// );
+
/// ```
+
InterceptorsWrapper createAuthInterceptor({
+
required Future<CovesSession?> Function()? sessionGetter,
+
required Future<bool> Function()? tokenRefresher,
+
required Future<void> Function()? signOutHandler,
+
required String serviceName,
+
required Dio dio,
+
}) {
+
return InterceptorsWrapper(
+
onRequest: (options, handler) async {
+
// Fetch fresh token before each request
+
final session = await sessionGetter?.call();
+
if (session != null) {
+
options.headers['Authorization'] = 'Bearer ${session.token}';
+
if (kDebugMode) {
+
debugPrint('๐Ÿ” $serviceName: Adding fresh Authorization header');
+
}
+
} else {
+
if (kDebugMode) {
+
debugPrint(
+
'โš ๏ธ $serviceName: Session getter returned null - '
+
'making unauthenticated request',
+
);
+
}
+
}
+
return handler.next(options);
+
},
+
onError: (error, handler) async {
+
// Handle 401 errors with automatic token refresh
+
if (error.response?.statusCode == 401 && tokenRefresher != null) {
+
if (kDebugMode) {
+
debugPrint(
+
'๐Ÿ”„ $serviceName: 401 detected, attempting token refresh...',
+
);
+
}
+
+
// Check if we already retried this request (prevent infinite loop)
+
if (error.requestOptions.extra['retried'] == true) {
+
if (kDebugMode) {
+
debugPrint(
+
'โš ๏ธ $serviceName: Request already retried after token refresh, '
+
'signing out user',
+
);
+
}
+
// Already retried once, don't retry again
+
if (signOutHandler != null) {
+
await signOutHandler();
+
}
+
return handler.next(error);
+
}
+
+
try {
+
// Attempt to refresh the token
+
final refreshSucceeded = await tokenRefresher();
+
+
if (refreshSucceeded) {
+
if (kDebugMode) {
+
debugPrint(
+
'โœ… $serviceName: Token refresh successful, retrying request',
+
);
+
}
+
+
// Get the new session
+
final newSession = await sessionGetter?.call();
+
+
if (newSession != null) {
+
// Mark this request as retried to prevent infinite loops
+
error.requestOptions.extra['retried'] = true;
+
+
// Update the Authorization header with the new token
+
error.requestOptions.headers['Authorization'] =
+
'Bearer ${newSession.token}';
+
+
// Retry the original request with the new token
+
try {
+
final response = await dio.fetch(error.requestOptions);
+
return handler.resolve(response);
+
} on DioException catch (retryError) {
+
// If retry failed with 401 and already retried, we already
+
// signed out in the retry limit check above, so just pass
+
// the error through without signing out again
+
if (retryError.response?.statusCode == 401 &&
+
retryError.requestOptions.extra['retried'] == true) {
+
return handler.next(retryError);
+
}
+
// For other errors during retry, rethrow to outer catch
+
rethrow;
+
}
+
}
+
}
+
+
// Refresh failed, sign out the user
+
if (kDebugMode) {
+
debugPrint(
+
'โŒ $serviceName: Token refresh failed, signing out user',
+
);
+
}
+
if (signOutHandler != null) {
+
await signOutHandler();
+
}
+
} on Exception catch (e) {
+
if (kDebugMode) {
+
debugPrint('โŒ $serviceName: Error during token refresh: $e');
+
}
+
// Only sign out if we haven't already (avoid double sign-out)
+
// Check if this is a DioException from a retried request
+
final isRetriedRequest =
+
e is DioException &&
+
e.response?.statusCode == 401 &&
+
e.requestOptions.extra['retried'] == true;
+
+
if (!isRetriedRequest && signOutHandler != null) {
+
await signOutHandler();
+
}
+
}
+
}
+
+
// Log the error for debugging
+
if (kDebugMode) {
+
debugPrint('โŒ $serviceName API Error: ${error.message}');
+
if (error.response != null) {
+
debugPrint(' Status: ${error.response?.statusCode}');
+
debugPrint(' Data: ${error.response?.data}');
+
}
+
}
+
return handler.next(error);
+
},
+
);
+
}
+11
lib/main.dart
···
import 'screens/home/main_shell_screen.dart';
import 'screens/home/post_detail_screen.dart';
import 'screens/landing_screen.dart';
+
import 'services/comment_service.dart';
import 'services/streamable_service.dart';
import 'services/vote_service.dart';
import 'widgets/loading_error_states.dart';
···
signOutHandler: authProvider.signOut,
);
+
// Initialize comment service with auth callbacks
+
// Comments go through the Coves backend (which proxies to PDS with DPoP)
+
final commentService = CommentService(
+
sessionGetter: () async => authProvider.session,
+
tokenRefresher: authProvider.refreshToken,
+
signOutHandler: authProvider.signOut,
+
);
+
runApp(
MultiProvider(
providers: [
···
(context) => CommentsProvider(
authProvider,
voteProvider: context.read<VoteProvider>(),
+
commentService: commentService,
),
update: (context, auth, vote, previous) {
// Reuse existing provider to maintain state across rebuilds
···
CommentsProvider(
auth,
voteProvider: vote,
+
commentService: commentService,
);
},
),
+91 -44
test/providers/comments_provider_test.mocks.dart
···
// Do not manually edit this file.
// ignore_for_file: no_leading_underscores_for_library_prefixes
-
import 'dart:async' as _i5;
-
import 'dart:ui' as _i6;
+
import 'dart:async' as _i6;
+
import 'dart:ui' as _i7;
import 'package:coves_flutter/models/comment.dart' as _i3;
import 'package:coves_flutter/models/post.dart' as _i2;
-
import 'package:coves_flutter/providers/auth_provider.dart' as _i4;
-
import 'package:coves_flutter/providers/vote_provider.dart' as _i8;
-
import 'package:coves_flutter/services/coves_api_service.dart' as _i7;
+
import 'package:coves_flutter/providers/auth_provider.dart' as _i5;
+
import 'package:coves_flutter/providers/vote_provider.dart' as _i9;
+
import 'package:coves_flutter/services/comment_service.dart' as _i4;
+
import 'package:coves_flutter/services/coves_api_service.dart' as _i8;
import 'package:mockito/mockito.dart' as _i1;
// ignore_for_file: type=lint
···
: super(parent, parentInvocation);
}
+
class _FakeCreateCommentResponse_2 extends _i1.SmartFake
+
implements _i4.CreateCommentResponse {
+
_FakeCreateCommentResponse_2(Object parent, Invocation parentInvocation)
+
: super(parent, parentInvocation);
+
}
+
/// A class which mocks [AuthProvider].
///
/// See the documentation for Mockito's code generation for more information.
-
class MockAuthProvider extends _i1.Mock implements _i4.AuthProvider {
+
class MockAuthProvider extends _i1.Mock implements _i5.AuthProvider {
MockAuthProvider() {
_i1.throwOnMissingStub(this);
}
···
as bool);
@override
-
_i5.Future<String?> getAccessToken() =>
+
_i6.Future<String?> getAccessToken() =>
(super.noSuchMethod(
Invocation.method(#getAccessToken, []),
-
returnValue: _i5.Future<String?>.value(),
+
returnValue: _i6.Future<String?>.value(),
)
-
as _i5.Future<String?>);
+
as _i6.Future<String?>);
@override
-
_i5.Future<void> initialize() =>
+
_i6.Future<void> initialize() =>
(super.noSuchMethod(
Invocation.method(#initialize, []),
-
returnValue: _i5.Future<void>.value(),
-
returnValueForMissingStub: _i5.Future<void>.value(),
+
returnValue: _i6.Future<void>.value(),
+
returnValueForMissingStub: _i6.Future<void>.value(),
)
-
as _i5.Future<void>);
+
as _i6.Future<void>);
@override
-
_i5.Future<void> signIn(String? handle) =>
+
_i6.Future<void> signIn(String? handle) =>
(super.noSuchMethod(
Invocation.method(#signIn, [handle]),
-
returnValue: _i5.Future<void>.value(),
-
returnValueForMissingStub: _i5.Future<void>.value(),
+
returnValue: _i6.Future<void>.value(),
+
returnValueForMissingStub: _i6.Future<void>.value(),
)
-
as _i5.Future<void>);
+
as _i6.Future<void>);
@override
-
_i5.Future<void> signOut() =>
+
_i6.Future<void> signOut() =>
(super.noSuchMethod(
Invocation.method(#signOut, []),
-
returnValue: _i5.Future<void>.value(),
-
returnValueForMissingStub: _i5.Future<void>.value(),
+
returnValue: _i6.Future<void>.value(),
+
returnValueForMissingStub: _i6.Future<void>.value(),
)
-
as _i5.Future<void>);
+
as _i6.Future<void>);
@override
-
_i5.Future<bool> refreshToken() =>
+
_i6.Future<bool> refreshToken() =>
(super.noSuchMethod(
Invocation.method(#refreshToken, []),
-
returnValue: _i5.Future<bool>.value(false),
+
returnValue: _i6.Future<bool>.value(false),
)
-
as _i5.Future<bool>);
+
as _i6.Future<bool>);
@override
void clearError() => super.noSuchMethod(
···
);
@override
-
void addListener(_i6.VoidCallback? listener) => super.noSuchMethod(
+
void addListener(_i7.VoidCallback? listener) => super.noSuchMethod(
Invocation.method(#addListener, [listener]),
returnValueForMissingStub: null,
);
@override
-
void removeListener(_i6.VoidCallback? listener) => super.noSuchMethod(
+
void removeListener(_i7.VoidCallback? listener) => super.noSuchMethod(
Invocation.method(#removeListener, [listener]),
returnValueForMissingStub: null,
);
···
/// A class which mocks [CovesApiService].
///
/// See the documentation for Mockito's code generation for more information.
-
class MockCovesApiService extends _i1.Mock implements _i7.CovesApiService {
+
class MockCovesApiService extends _i1.Mock implements _i8.CovesApiService {
MockCovesApiService() {
_i1.throwOnMissingStub(this);
}
@override
-
_i5.Future<_i2.TimelineResponse> getTimeline({
+
_i6.Future<_i2.TimelineResponse> getTimeline({
String? sort = 'hot',
String? timeframe,
int? limit = 15,
···
#limit: limit,
#cursor: cursor,
}),
-
returnValue: _i5.Future<_i2.TimelineResponse>.value(
+
returnValue: _i6.Future<_i2.TimelineResponse>.value(
_FakeTimelineResponse_0(
this,
Invocation.method(#getTimeline, [], {
···
),
),
)
-
as _i5.Future<_i2.TimelineResponse>);
+
as _i6.Future<_i2.TimelineResponse>);
@override
-
_i5.Future<_i2.TimelineResponse> getDiscover({
+
_i6.Future<_i2.TimelineResponse> getDiscover({
String? sort = 'hot',
String? timeframe,
int? limit = 15,
···
#limit: limit,
#cursor: cursor,
}),
-
returnValue: _i5.Future<_i2.TimelineResponse>.value(
+
returnValue: _i6.Future<_i2.TimelineResponse>.value(
_FakeTimelineResponse_0(
this,
Invocation.method(#getDiscover, [], {
···
),
),
)
-
as _i5.Future<_i2.TimelineResponse>);
+
as _i6.Future<_i2.TimelineResponse>);
@override
-
_i5.Future<_i3.CommentsResponse> getComments({
+
_i6.Future<_i3.CommentsResponse> getComments({
required String? postUri,
String? sort = 'hot',
String? timeframe,
···
#limit: limit,
#cursor: cursor,
}),
-
returnValue: _i5.Future<_i3.CommentsResponse>.value(
+
returnValue: _i6.Future<_i3.CommentsResponse>.value(
_FakeCommentsResponse_1(
this,
Invocation.method(#getComments, [], {
···
),
),
)
-
as _i5.Future<_i3.CommentsResponse>);
+
as _i6.Future<_i3.CommentsResponse>);
@override
void dispose() => super.noSuchMethod(
···
/// A class which mocks [VoteProvider].
///
/// See the documentation for Mockito's code generation for more information.
-
class MockVoteProvider extends _i1.Mock implements _i8.VoteProvider {
+
class MockVoteProvider extends _i1.Mock implements _i9.VoteProvider {
MockVoteProvider() {
_i1.throwOnMissingStub(this);
}
···
);
@override
-
_i8.VoteState? getVoteState(String? postUri) =>
+
_i9.VoteState? getVoteState(String? postUri) =>
(super.noSuchMethod(Invocation.method(#getVoteState, [postUri]))
-
as _i8.VoteState?);
+
as _i9.VoteState?);
@override
bool isLiked(String? postUri) =>
···
as int);
@override
-
_i5.Future<bool> toggleVote({
+
_i6.Future<bool> toggleVote({
required String? postUri,
required String? postCid,
String? direction = 'up',
···
#postCid: postCid,
#direction: direction,
}),
-
returnValue: _i5.Future<bool>.value(false),
+
returnValue: _i6.Future<bool>.value(false),
)
-
as _i5.Future<bool>);
+
as _i6.Future<bool>);
@override
void setInitialVoteState({
···
);
@override
-
void addListener(_i6.VoidCallback? listener) => super.noSuchMethod(
+
void addListener(_i7.VoidCallback? listener) => super.noSuchMethod(
Invocation.method(#addListener, [listener]),
returnValueForMissingStub: null,
);
@override
-
void removeListener(_i6.VoidCallback? listener) => super.noSuchMethod(
+
void removeListener(_i7.VoidCallback? listener) => super.noSuchMethod(
Invocation.method(#removeListener, [listener]),
returnValueForMissingStub: null,
);
···
returnValueForMissingStub: null,
);
}
+
+
/// A class which mocks [CommentService].
+
///
+
/// See the documentation for Mockito's code generation for more information.
+
class MockCommentService extends _i1.Mock implements _i4.CommentService {
+
MockCommentService() {
+
_i1.throwOnMissingStub(this);
+
}
+
+
@override
+
_i6.Future<_i4.CreateCommentResponse> createComment({
+
required String? rootUri,
+
required String? rootCid,
+
required String? parentUri,
+
required String? parentCid,
+
required String? content,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(#createComment, [], {
+
#rootUri: rootUri,
+
#rootCid: rootCid,
+
#parentUri: parentUri,
+
#parentCid: parentCid,
+
#content: content,
+
}),
+
returnValue: _i6.Future<_i4.CreateCommentResponse>.value(
+
_FakeCreateCommentResponse_2(
+
this,
+
Invocation.method(#createComment, [], {
+
#rootUri: rootUri,
+
#rootCid: rootCid,
+
#parentUri: parentUri,
+
#parentCid: parentCid,
+
#content: content,
+
}),
+
),
+
),
+
)
+
as _i6.Future<_i4.CreateCommentResponse>);
+
}
+357
test/services/comment_service_test.dart
···
+
import 'package:coves_flutter/models/coves_session.dart';
+
import 'package:coves_flutter/services/api_exceptions.dart';
+
import 'package:coves_flutter/services/comment_service.dart';
+
import 'package:dio/dio.dart';
+
import 'package:flutter_test/flutter_test.dart';
+
import 'package:mockito/annotations.dart';
+
import 'package:mockito/mockito.dart';
+
+
import 'comment_service_test.mocks.dart';
+
+
@GenerateMocks([Dio])
+
void main() {
+
group('CommentService', () {
+
group('CreateCommentResponse', () {
+
test('should create response with uri and cid', () {
+
const response = CreateCommentResponse(
+
uri: 'at://did:plc:test/social.coves.community.comment/123',
+
cid: 'bafy123',
+
);
+
+
expect(
+
response.uri,
+
'at://did:plc:test/social.coves.community.comment/123',
+
);
+
expect(response.cid, 'bafy123');
+
});
+
});
+
+
group('createComment', () {
+
late MockDio mockDio;
+
late CommentService commentService;
+
late CovesSession testSession;
+
+
setUp(() {
+
mockDio = MockDio();
+
testSession = CovesSession(
+
token: 'test-token',
+
did: 'did:plc:test',
+
sessionId: 'test-session-id',
+
handle: 'test.user',
+
);
+
+
// Setup default interceptors behavior
+
when(mockDio.interceptors).thenReturn(Interceptors());
+
+
commentService = CommentService(
+
sessionGetter: () async => testSession,
+
tokenRefresher: () async => true,
+
signOutHandler: () async {},
+
dio: mockDio,
+
);
+
});
+
+
test('should create comment successfully', () async {
+
when(
+
mockDio.post<Map<String, dynamic>>(
+
'/xrpc/social.coves.community.comment.create',
+
data: anyNamed('data'),
+
),
+
).thenAnswer(
+
(_) async => Response(
+
requestOptions: RequestOptions(path: ''),
+
statusCode: 200,
+
data: {
+
'uri': 'at://did:plc:test/social.coves.community.comment/abc123',
+
'cid': 'bafy123',
+
},
+
),
+
);
+
+
final response = await commentService.createComment(
+
rootUri: 'at://did:plc:author/social.coves.post.record/post123',
+
rootCid: 'rootCid123',
+
parentUri: 'at://did:plc:author/social.coves.post.record/post123',
+
parentCid: 'parentCid123',
+
content: 'This is a test comment',
+
);
+
+
expect(
+
response.uri,
+
'at://did:plc:test/social.coves.community.comment/abc123',
+
);
+
expect(response.cid, 'bafy123');
+
+
verify(
+
mockDio.post<Map<String, dynamic>>(
+
'/xrpc/social.coves.community.comment.create',
+
data: {
+
'reply': {
+
'root': {
+
'uri': 'at://did:plc:author/social.coves.post.record/post123',
+
'cid': 'rootCid123',
+
},
+
'parent': {
+
'uri': 'at://did:plc:author/social.coves.post.record/post123',
+
'cid': 'parentCid123',
+
},
+
},
+
'content': 'This is a test comment',
+
},
+
),
+
).called(1);
+
});
+
+
test('should throw AuthenticationException when no session', () async {
+
final serviceWithoutSession = CommentService(
+
sessionGetter: () async => null,
+
tokenRefresher: () async => true,
+
signOutHandler: () async {},
+
dio: mockDio,
+
);
+
+
expect(
+
() => serviceWithoutSession.createComment(
+
rootUri: 'at://did:plc:author/post/123',
+
rootCid: 'rootCid',
+
parentUri: 'at://did:plc:author/post/123',
+
parentCid: 'parentCid',
+
content: 'Test comment',
+
),
+
throwsA(isA<AuthenticationException>()),
+
);
+
});
+
+
test('should throw ApiException on network error', () async {
+
when(
+
mockDio.post<Map<String, dynamic>>(
+
'/xrpc/social.coves.community.comment.create',
+
data: anyNamed('data'),
+
),
+
).thenThrow(
+
DioException(
+
requestOptions: RequestOptions(path: ''),
+
type: DioExceptionType.connectionError,
+
message: 'Connection failed',
+
),
+
);
+
+
expect(
+
() => commentService.createComment(
+
rootUri: 'at://did:plc:author/post/123',
+
rootCid: 'rootCid',
+
parentUri: 'at://did:plc:author/post/123',
+
parentCid: 'parentCid',
+
content: 'Test comment',
+
),
+
throwsA(isA<ApiException>()),
+
);
+
});
+
+
test('should throw AuthenticationException on 401 response', () async {
+
when(
+
mockDio.post<Map<String, dynamic>>(
+
'/xrpc/social.coves.community.comment.create',
+
data: anyNamed('data'),
+
),
+
).thenThrow(
+
DioException(
+
requestOptions: RequestOptions(path: ''),
+
type: DioExceptionType.badResponse,
+
response: Response(
+
requestOptions: RequestOptions(path: ''),
+
statusCode: 401,
+
data: {'error': 'Unauthorized'},
+
),
+
),
+
);
+
+
expect(
+
() => commentService.createComment(
+
rootUri: 'at://did:plc:author/post/123',
+
rootCid: 'rootCid',
+
parentUri: 'at://did:plc:author/post/123',
+
parentCid: 'parentCid',
+
content: 'Test comment',
+
),
+
throwsA(isA<AuthenticationException>()),
+
);
+
});
+
+
test('should throw ApiException on invalid response (null data)', () async {
+
when(
+
mockDio.post<Map<String, dynamic>>(
+
'/xrpc/social.coves.community.comment.create',
+
data: anyNamed('data'),
+
),
+
).thenAnswer(
+
(_) async => Response(
+
requestOptions: RequestOptions(path: ''),
+
statusCode: 200,
+
data: null,
+
),
+
);
+
+
expect(
+
() => commentService.createComment(
+
rootUri: 'at://did:plc:author/post/123',
+
rootCid: 'rootCid',
+
parentUri: 'at://did:plc:author/post/123',
+
parentCid: 'parentCid',
+
content: 'Test comment',
+
),
+
throwsA(
+
isA<ApiException>().having(
+
(e) => e.message,
+
'message',
+
contains('no data'),
+
),
+
),
+
);
+
});
+
+
test('should throw ApiException on invalid response (missing uri)', () async {
+
when(
+
mockDio.post<Map<String, dynamic>>(
+
'/xrpc/social.coves.community.comment.create',
+
data: anyNamed('data'),
+
),
+
).thenAnswer(
+
(_) async => Response(
+
requestOptions: RequestOptions(path: ''),
+
statusCode: 200,
+
data: {'cid': 'bafy123'},
+
),
+
);
+
+
expect(
+
() => commentService.createComment(
+
rootUri: 'at://did:plc:author/post/123',
+
rootCid: 'rootCid',
+
parentUri: 'at://did:plc:author/post/123',
+
parentCid: 'parentCid',
+
content: 'Test comment',
+
),
+
throwsA(
+
isA<ApiException>().having(
+
(e) => e.message,
+
'message',
+
contains('missing uri'),
+
),
+
),
+
);
+
});
+
+
test('should throw ApiException on invalid response (empty uri)', () async {
+
when(
+
mockDio.post<Map<String, dynamic>>(
+
'/xrpc/social.coves.community.comment.create',
+
data: anyNamed('data'),
+
),
+
).thenAnswer(
+
(_) async => Response(
+
requestOptions: RequestOptions(path: ''),
+
statusCode: 200,
+
data: {'uri': '', 'cid': 'bafy123'},
+
),
+
);
+
+
expect(
+
() => commentService.createComment(
+
rootUri: 'at://did:plc:author/post/123',
+
rootCid: 'rootCid',
+
parentUri: 'at://did:plc:author/post/123',
+
parentCid: 'parentCid',
+
content: 'Test comment',
+
),
+
throwsA(
+
isA<ApiException>().having(
+
(e) => e.message,
+
'message',
+
contains('missing uri'),
+
),
+
),
+
);
+
});
+
+
test('should throw ApiException on server error', () async {
+
when(
+
mockDio.post<Map<String, dynamic>>(
+
'/xrpc/social.coves.community.comment.create',
+
data: anyNamed('data'),
+
),
+
).thenThrow(
+
DioException(
+
requestOptions: RequestOptions(path: ''),
+
type: DioExceptionType.badResponse,
+
response: Response(
+
requestOptions: RequestOptions(path: ''),
+
statusCode: 500,
+
data: {'error': 'Internal server error'},
+
),
+
message: 'Internal server error',
+
),
+
);
+
+
expect(
+
() => commentService.createComment(
+
rootUri: 'at://did:plc:author/post/123',
+
rootCid: 'rootCid',
+
parentUri: 'at://did:plc:author/post/123',
+
parentCid: 'parentCid',
+
content: 'Test comment',
+
),
+
throwsA(isA<ApiException>()),
+
);
+
});
+
+
test('should send correct parent for nested reply', () async {
+
when(
+
mockDio.post<Map<String, dynamic>>(
+
'/xrpc/social.coves.community.comment.create',
+
data: anyNamed('data'),
+
),
+
).thenAnswer(
+
(_) async => Response(
+
requestOptions: RequestOptions(path: ''),
+
statusCode: 200,
+
data: {
+
'uri': 'at://did:plc:test/social.coves.community.comment/reply1',
+
'cid': 'bafyReply',
+
},
+
),
+
);
+
+
await commentService.createComment(
+
rootUri: 'at://did:plc:author/social.coves.post.record/post123',
+
rootCid: 'postCid',
+
parentUri:
+
'at://did:plc:commenter/social.coves.community.comment/comment1',
+
parentCid: 'commentCid',
+
content: 'This is a nested reply',
+
);
+
+
verify(
+
mockDio.post<Map<String, dynamic>>(
+
'/xrpc/social.coves.community.comment.create',
+
data: {
+
'reply': {
+
'root': {
+
'uri': 'at://did:plc:author/social.coves.post.record/post123',
+
'cid': 'postCid',
+
},
+
'parent': {
+
'uri':
+
'at://did:plc:commenter/social.coves.community.comment/'
+
'comment1',
+
'cid': 'commentCid',
+
},
+
},
+
'content': 'This is a nested reply',
+
},
+
),
+
).called(1);
+
});
+
});
+
});
+
}
+806
test/services/comment_service_test.mocks.dart
···
+
// Mocks generated by Mockito 5.4.6 from annotations
+
// in coves_flutter/test/services/comment_service_test.dart.
+
// Do not manually edit this file.
+
+
// ignore_for_file: no_leading_underscores_for_library_prefixes
+
import 'dart:async' as _i8;
+
+
import 'package:dio/src/adapter.dart' as _i4;
+
import 'package:dio/src/cancel_token.dart' as _i9;
+
import 'package:dio/src/dio.dart' as _i7;
+
import 'package:dio/src/dio_mixin.dart' as _i3;
+
import 'package:dio/src/options.dart' as _i2;
+
import 'package:dio/src/response.dart' as _i6;
+
import 'package:dio/src/transformer.dart' as _i5;
+
import 'package:mockito/mockito.dart' as _i1;
+
+
// ignore_for_file: type=lint
+
// ignore_for_file: avoid_redundant_argument_values
+
// ignore_for_file: avoid_setters_without_getters
+
// ignore_for_file: comment_references
+
// ignore_for_file: deprecated_member_use
+
// ignore_for_file: deprecated_member_use_from_same_package
+
// ignore_for_file: implementation_imports
+
// ignore_for_file: invalid_use_of_visible_for_testing_member
+
// ignore_for_file: must_be_immutable
+
// ignore_for_file: prefer_const_constructors
+
// ignore_for_file: unnecessary_parenthesis
+
// ignore_for_file: camel_case_types
+
// ignore_for_file: subtype_of_sealed_class
+
// ignore_for_file: invalid_use_of_internal_member
+
+
class _FakeBaseOptions_0 extends _i1.SmartFake implements _i2.BaseOptions {
+
_FakeBaseOptions_0(Object parent, Invocation parentInvocation)
+
: super(parent, parentInvocation);
+
}
+
+
class _FakeInterceptors_1 extends _i1.SmartFake implements _i3.Interceptors {
+
_FakeInterceptors_1(Object parent, Invocation parentInvocation)
+
: super(parent, parentInvocation);
+
}
+
+
class _FakeHttpClientAdapter_2 extends _i1.SmartFake
+
implements _i4.HttpClientAdapter {
+
_FakeHttpClientAdapter_2(Object parent, Invocation parentInvocation)
+
: super(parent, parentInvocation);
+
}
+
+
class _FakeTransformer_3 extends _i1.SmartFake implements _i5.Transformer {
+
_FakeTransformer_3(Object parent, Invocation parentInvocation)
+
: super(parent, parentInvocation);
+
}
+
+
class _FakeResponse_4<T1> extends _i1.SmartFake implements _i6.Response<T1> {
+
_FakeResponse_4(Object parent, Invocation parentInvocation)
+
: super(parent, parentInvocation);
+
}
+
+
class _FakeDio_5 extends _i1.SmartFake implements _i7.Dio {
+
_FakeDio_5(Object parent, Invocation parentInvocation)
+
: super(parent, parentInvocation);
+
}
+
+
/// A class which mocks [Dio].
+
///
+
/// See the documentation for Mockito's code generation for more information.
+
class MockDio extends _i1.Mock implements _i7.Dio {
+
MockDio() {
+
_i1.throwOnMissingStub(this);
+
}
+
+
@override
+
_i2.BaseOptions get options =>
+
(super.noSuchMethod(
+
Invocation.getter(#options),
+
returnValue: _FakeBaseOptions_0(this, Invocation.getter(#options)),
+
)
+
as _i2.BaseOptions);
+
+
@override
+
_i3.Interceptors get interceptors =>
+
(super.noSuchMethod(
+
Invocation.getter(#interceptors),
+
returnValue: _FakeInterceptors_1(
+
this,
+
Invocation.getter(#interceptors),
+
),
+
)
+
as _i3.Interceptors);
+
+
@override
+
_i4.HttpClientAdapter get httpClientAdapter =>
+
(super.noSuchMethod(
+
Invocation.getter(#httpClientAdapter),
+
returnValue: _FakeHttpClientAdapter_2(
+
this,
+
Invocation.getter(#httpClientAdapter),
+
),
+
)
+
as _i4.HttpClientAdapter);
+
+
@override
+
_i5.Transformer get transformer =>
+
(super.noSuchMethod(
+
Invocation.getter(#transformer),
+
returnValue: _FakeTransformer_3(
+
this,
+
Invocation.getter(#transformer),
+
),
+
)
+
as _i5.Transformer);
+
+
@override
+
set options(_i2.BaseOptions? value) => super.noSuchMethod(
+
Invocation.setter(#options, value),
+
returnValueForMissingStub: null,
+
);
+
+
@override
+
set httpClientAdapter(_i4.HttpClientAdapter? value) => super.noSuchMethod(
+
Invocation.setter(#httpClientAdapter, value),
+
returnValueForMissingStub: null,
+
);
+
+
@override
+
set transformer(_i5.Transformer? value) => super.noSuchMethod(
+
Invocation.setter(#transformer, value),
+
returnValueForMissingStub: null,
+
);
+
+
@override
+
void close({bool? force = false}) => super.noSuchMethod(
+
Invocation.method(#close, [], {#force: force}),
+
returnValueForMissingStub: null,
+
);
+
+
@override
+
_i8.Future<_i6.Response<T>> head<T>(
+
String? path, {
+
Object? data,
+
Map<String, dynamic>? queryParameters,
+
_i2.Options? options,
+
_i9.CancelToken? cancelToken,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#head,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
},
+
),
+
returnValue: _i8.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#head,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
},
+
),
+
),
+
),
+
)
+
as _i8.Future<_i6.Response<T>>);
+
+
@override
+
_i8.Future<_i6.Response<T>> headUri<T>(
+
Uri? uri, {
+
Object? data,
+
_i2.Options? options,
+
_i9.CancelToken? cancelToken,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#headUri,
+
[uri],
+
{#data: data, #options: options, #cancelToken: cancelToken},
+
),
+
returnValue: _i8.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#headUri,
+
[uri],
+
{#data: data, #options: options, #cancelToken: cancelToken},
+
),
+
),
+
),
+
)
+
as _i8.Future<_i6.Response<T>>);
+
+
@override
+
_i8.Future<_i6.Response<T>> get<T>(
+
String? path, {
+
Object? data,
+
Map<String, dynamic>? queryParameters,
+
_i2.Options? options,
+
_i9.CancelToken? cancelToken,
+
_i2.ProgressCallback? onReceiveProgress,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#get,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
returnValue: _i8.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#get,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
),
+
),
+
)
+
as _i8.Future<_i6.Response<T>>);
+
+
@override
+
_i8.Future<_i6.Response<T>> getUri<T>(
+
Uri? uri, {
+
Object? data,
+
_i2.Options? options,
+
_i9.CancelToken? cancelToken,
+
_i2.ProgressCallback? onReceiveProgress,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#getUri,
+
[uri],
+
{
+
#data: data,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
returnValue: _i8.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#getUri,
+
[uri],
+
{
+
#data: data,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
),
+
),
+
)
+
as _i8.Future<_i6.Response<T>>);
+
+
@override
+
_i8.Future<_i6.Response<T>> post<T>(
+
String? path, {
+
Object? data,
+
Map<String, dynamic>? queryParameters,
+
_i2.Options? options,
+
_i9.CancelToken? cancelToken,
+
_i2.ProgressCallback? onSendProgress,
+
_i2.ProgressCallback? onReceiveProgress,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#post,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
returnValue: _i8.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#post,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
),
+
),
+
)
+
as _i8.Future<_i6.Response<T>>);
+
+
@override
+
_i8.Future<_i6.Response<T>> postUri<T>(
+
Uri? uri, {
+
Object? data,
+
_i2.Options? options,
+
_i9.CancelToken? cancelToken,
+
_i2.ProgressCallback? onSendProgress,
+
_i2.ProgressCallback? onReceiveProgress,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#postUri,
+
[uri],
+
{
+
#data: data,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
returnValue: _i8.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#postUri,
+
[uri],
+
{
+
#data: data,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
),
+
),
+
)
+
as _i8.Future<_i6.Response<T>>);
+
+
@override
+
_i8.Future<_i6.Response<T>> put<T>(
+
String? path, {
+
Object? data,
+
Map<String, dynamic>? queryParameters,
+
_i2.Options? options,
+
_i9.CancelToken? cancelToken,
+
_i2.ProgressCallback? onSendProgress,
+
_i2.ProgressCallback? onReceiveProgress,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#put,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
returnValue: _i8.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#put,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
),
+
),
+
)
+
as _i8.Future<_i6.Response<T>>);
+
+
@override
+
_i8.Future<_i6.Response<T>> putUri<T>(
+
Uri? uri, {
+
Object? data,
+
_i2.Options? options,
+
_i9.CancelToken? cancelToken,
+
_i2.ProgressCallback? onSendProgress,
+
_i2.ProgressCallback? onReceiveProgress,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#putUri,
+
[uri],
+
{
+
#data: data,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
returnValue: _i8.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#putUri,
+
[uri],
+
{
+
#data: data,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
),
+
),
+
)
+
as _i8.Future<_i6.Response<T>>);
+
+
@override
+
_i8.Future<_i6.Response<T>> patch<T>(
+
String? path, {
+
Object? data,
+
Map<String, dynamic>? queryParameters,
+
_i2.Options? options,
+
_i9.CancelToken? cancelToken,
+
_i2.ProgressCallback? onSendProgress,
+
_i2.ProgressCallback? onReceiveProgress,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#patch,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
returnValue: _i8.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#patch,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
),
+
),
+
)
+
as _i8.Future<_i6.Response<T>>);
+
+
@override
+
_i8.Future<_i6.Response<T>> patchUri<T>(
+
Uri? uri, {
+
Object? data,
+
_i2.Options? options,
+
_i9.CancelToken? cancelToken,
+
_i2.ProgressCallback? onSendProgress,
+
_i2.ProgressCallback? onReceiveProgress,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#patchUri,
+
[uri],
+
{
+
#data: data,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
returnValue: _i8.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#patchUri,
+
[uri],
+
{
+
#data: data,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
),
+
),
+
)
+
as _i8.Future<_i6.Response<T>>);
+
+
@override
+
_i8.Future<_i6.Response<T>> delete<T>(
+
String? path, {
+
Object? data,
+
Map<String, dynamic>? queryParameters,
+
_i2.Options? options,
+
_i9.CancelToken? cancelToken,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#delete,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
},
+
),
+
returnValue: _i8.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#delete,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
},
+
),
+
),
+
),
+
)
+
as _i8.Future<_i6.Response<T>>);
+
+
@override
+
_i8.Future<_i6.Response<T>> deleteUri<T>(
+
Uri? uri, {
+
Object? data,
+
_i2.Options? options,
+
_i9.CancelToken? cancelToken,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#deleteUri,
+
[uri],
+
{#data: data, #options: options, #cancelToken: cancelToken},
+
),
+
returnValue: _i8.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#deleteUri,
+
[uri],
+
{#data: data, #options: options, #cancelToken: cancelToken},
+
),
+
),
+
),
+
)
+
as _i8.Future<_i6.Response<T>>);
+
+
@override
+
_i8.Future<_i6.Response<dynamic>> download(
+
String? urlPath,
+
dynamic savePath, {
+
_i2.ProgressCallback? onReceiveProgress,
+
Map<String, dynamic>? queryParameters,
+
_i9.CancelToken? cancelToken,
+
bool? deleteOnError = true,
+
_i2.FileAccessMode? fileAccessMode = _i2.FileAccessMode.write,
+
String? lengthHeader = 'content-length',
+
Object? data,
+
_i2.Options? options,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#download,
+
[urlPath, savePath],
+
{
+
#onReceiveProgress: onReceiveProgress,
+
#queryParameters: queryParameters,
+
#cancelToken: cancelToken,
+
#deleteOnError: deleteOnError,
+
#fileAccessMode: fileAccessMode,
+
#lengthHeader: lengthHeader,
+
#data: data,
+
#options: options,
+
},
+
),
+
returnValue: _i8.Future<_i6.Response<dynamic>>.value(
+
_FakeResponse_4<dynamic>(
+
this,
+
Invocation.method(
+
#download,
+
[urlPath, savePath],
+
{
+
#onReceiveProgress: onReceiveProgress,
+
#queryParameters: queryParameters,
+
#cancelToken: cancelToken,
+
#deleteOnError: deleteOnError,
+
#fileAccessMode: fileAccessMode,
+
#lengthHeader: lengthHeader,
+
#data: data,
+
#options: options,
+
},
+
),
+
),
+
),
+
)
+
as _i8.Future<_i6.Response<dynamic>>);
+
+
@override
+
_i8.Future<_i6.Response<dynamic>> downloadUri(
+
Uri? uri,
+
dynamic savePath, {
+
_i2.ProgressCallback? onReceiveProgress,
+
_i9.CancelToken? cancelToken,
+
bool? deleteOnError = true,
+
_i2.FileAccessMode? fileAccessMode = _i2.FileAccessMode.write,
+
String? lengthHeader = 'content-length',
+
Object? data,
+
_i2.Options? options,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#downloadUri,
+
[uri, savePath],
+
{
+
#onReceiveProgress: onReceiveProgress,
+
#cancelToken: cancelToken,
+
#deleteOnError: deleteOnError,
+
#fileAccessMode: fileAccessMode,
+
#lengthHeader: lengthHeader,
+
#data: data,
+
#options: options,
+
},
+
),
+
returnValue: _i8.Future<_i6.Response<dynamic>>.value(
+
_FakeResponse_4<dynamic>(
+
this,
+
Invocation.method(
+
#downloadUri,
+
[uri, savePath],
+
{
+
#onReceiveProgress: onReceiveProgress,
+
#cancelToken: cancelToken,
+
#deleteOnError: deleteOnError,
+
#fileAccessMode: fileAccessMode,
+
#lengthHeader: lengthHeader,
+
#data: data,
+
#options: options,
+
},
+
),
+
),
+
),
+
)
+
as _i8.Future<_i6.Response<dynamic>>);
+
+
@override
+
_i8.Future<_i6.Response<T>> request<T>(
+
String? url, {
+
Object? data,
+
Map<String, dynamic>? queryParameters,
+
_i9.CancelToken? cancelToken,
+
_i2.Options? options,
+
_i2.ProgressCallback? onSendProgress,
+
_i2.ProgressCallback? onReceiveProgress,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#request,
+
[url],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#cancelToken: cancelToken,
+
#options: options,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
returnValue: _i8.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#request,
+
[url],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#cancelToken: cancelToken,
+
#options: options,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
),
+
),
+
)
+
as _i8.Future<_i6.Response<T>>);
+
+
@override
+
_i8.Future<_i6.Response<T>> requestUri<T>(
+
Uri? uri, {
+
Object? data,
+
_i9.CancelToken? cancelToken,
+
_i2.Options? options,
+
_i2.ProgressCallback? onSendProgress,
+
_i2.ProgressCallback? onReceiveProgress,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#requestUri,
+
[uri],
+
{
+
#data: data,
+
#cancelToken: cancelToken,
+
#options: options,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
returnValue: _i8.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#requestUri,
+
[uri],
+
{
+
#data: data,
+
#cancelToken: cancelToken,
+
#options: options,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
),
+
),
+
)
+
as _i8.Future<_i6.Response<T>>);
+
+
@override
+
_i8.Future<_i6.Response<T>> fetch<T>(_i2.RequestOptions? requestOptions) =>
+
(super.noSuchMethod(
+
Invocation.method(#fetch, [requestOptions]),
+
returnValue: _i8.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(#fetch, [requestOptions]),
+
),
+
),
+
)
+
as _i8.Future<_i6.Response<T>>);
+
+
@override
+
_i7.Dio clone({
+
_i2.BaseOptions? options,
+
_i3.Interceptors? interceptors,
+
_i4.HttpClientAdapter? httpClientAdapter,
+
_i5.Transformer? transformer,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(#clone, [], {
+
#options: options,
+
#interceptors: interceptors,
+
#httpClientAdapter: httpClientAdapter,
+
#transformer: transformer,
+
}),
+
returnValue: _FakeDio_5(
+
this,
+
Invocation.method(#clone, [], {
+
#options: options,
+
#interceptors: interceptors,
+
#httpClientAdapter: httpClientAdapter,
+
#transformer: transformer,
+
}),
+
),
+
)
+
as _i7.Dio);
+
}
+42 -5
lib/providers/feed_provider.dart
···
import 'auth_provider.dart';
import 'vote_provider.dart';
+
/// Feed types available in the app
+
enum FeedType {
+
/// All posts across the network
+
discover,
+
+
/// Posts from subscribed communities (authenticated only)
+
forYou,
+
}
+
/// Feed Provider
///
/// Manages feed state and fetching logic.
···
if (kDebugMode) {
debugPrint('๐Ÿ”’ User signed out - clearing feed');
}
+
// Reset feed type to Discover since For You requires auth
+
_feedType = FeedType.discover;
reset();
// Automatically load the public discover feed
loadFeed(refresh: true);
···
// Feed configuration
String _sort = 'hot';
String? _timeframe;
+
FeedType _feedType = FeedType.discover;
// Time update mechanism for periodic UI refreshes
Timer? _timeUpdateTimer;
···
String get sort => _sort;
String? get timeframe => _timeframe;
DateTime? get currentTime => _currentTime;
+
FeedType get feedType => _feedType;
+
+
/// Check if For You feed is available (requires authentication)
+
bool get isForYouAvailable => _authProvider.isAuthenticated;
/// Start periodic time updates for "time ago" strings
///
···
}
}
-
/// Load feed based on authentication state (business logic
-
/// encapsulation)
+
/// Load feed based on current feed type
///
/// This method encapsulates the business logic of deciding which feed
-
/// to fetch. Previously this logic was in the UI layer (FeedScreen),
-
/// violating clean architecture.
+
/// to fetch based on the selected feed type.
Future<void> loadFeed({bool refresh = false}) async {
-
if (_authProvider.isAuthenticated) {
+
// For You requires authentication - fall back to Discover if not
+
if (_feedType == FeedType.forYou && _authProvider.isAuthenticated) {
await fetchTimeline(refresh: refresh);
} else {
await fetchDiscover(refresh: refresh);
···
}
}
+
/// Switch feed type and reload
+
Future<void> setFeedType(FeedType type) async {
+
if (_feedType == type) {
+
return;
+
}
+
+
// For You requires authentication
+
if (type == FeedType.forYou && !_authProvider.isAuthenticated) {
+
return;
+
}
+
+
_feedType = type;
+
// Reset pagination state but keep posts visible until new feed loads
+
_cursor = null;
+
_hasMore = true;
+
_error = null;
+
notifyListeners();
+
+
// Load new feed - old posts stay visible until new ones arrive
+
await loadFeed(refresh: true);
+
}
+
/// Common feed fetching logic (DRY principle - eliminates code
/// duplication)
Future<void> _fetchFeed({
+12 -8
lib/screens/home/search_screen.dart lib/screens/home/communities_screen.dart
···
import '../../constants/app_colors.dart';
-
class SearchScreen extends StatelessWidget {
-
const SearchScreen({super.key});
+
class CommunitiesScreen extends StatelessWidget {
+
const CommunitiesScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
-
backgroundColor: const Color(0xFF0B0F14),
+
backgroundColor: AppColors.background,
appBar: AppBar(
-
backgroundColor: const Color(0xFF0B0F14),
+
backgroundColor: AppColors.background,
foregroundColor: Colors.white,
-
title: const Text('Search'),
+
title: const Text('Communities'),
automaticallyImplyLeading: false,
),
body: const Center(
···
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
-
Icon(Icons.search, size: 64, color: AppColors.primary),
+
Icon(
+
Icons.workspaces_outlined,
+
size: 64,
+
color: AppColors.primary,
+
),
SizedBox(height: 24),
Text(
-
'Search',
+
'Communities',
style: TextStyle(
fontSize: 28,
color: Colors.white,
···
),
SizedBox(height: 16),
Text(
-
'Search communities and conversations',
+
'Discover and join communities',
style: TextStyle(fontSize: 16, color: Color(0xFFB6C2D2)),
textAlign: TextAlign.center,
),
+162 -14
lib/screens/home/feed_screen.dart
···
import '../../models/post.dart';
import '../../providers/auth_provider.dart';
import '../../providers/feed_provider.dart';
+
import '../../widgets/icons/bluesky_icons.dart';
import '../../widgets/post_card.dart';
+
/// Header layout constants
+
const double _kHeaderHeight = 44;
+
const double _kTabUnderlineWidth = 28;
+
const double _kTabUnderlineHeight = 3;
+
const double _kHeaderContentPadding = _kHeaderHeight;
+
class FeedScreen extends StatefulWidget {
-
const FeedScreen({super.key});
+
const FeedScreen({super.key, this.onSearchTap});
+
+
/// Callback when search icon is tapped (to switch to communities tab)
+
final VoidCallback? onSearchTap;
@override
State<FeedScreen> createState() => _FeedScreenState();
···
);
final isLoading = context.select<FeedProvider, bool>((p) => p.isLoading);
final error = context.select<FeedProvider, String?>((p) => p.error);
+
final feedType = context.select<FeedProvider, FeedType>(
+
(p) => p.feedType,
+
);
// IMPORTANT: This relies on FeedProvider creating new list instances
// (_posts = [..._posts, ...response.feed]) rather than mutating in-place.
···
return Scaffold(
backgroundColor: AppColors.background,
-
appBar: AppBar(
-
backgroundColor: AppColors.background,
-
foregroundColor: AppColors.textPrimary,
-
title: Text(isAuthenticated ? 'Feed' : 'Explore'),
-
automaticallyImplyLeading: false,
-
),
body: SafeArea(
-
child: _buildBody(
-
isLoading: isLoading,
-
error: error,
-
posts: posts,
-
isLoadingMore: isLoadingMore,
-
isAuthenticated: isAuthenticated,
-
currentTime: currentTime,
+
child: Stack(
+
children: [
+
// Feed content (behind header)
+
_buildBody(
+
isLoading: isLoading,
+
error: error,
+
posts: posts,
+
isLoadingMore: isLoadingMore,
+
isAuthenticated: isAuthenticated,
+
currentTime: currentTime,
+
),
+
// Transparent header overlay
+
_buildHeader(
+
feedType: feedType,
+
isAuthenticated: isAuthenticated,
+
),
+
],
+
),
+
),
+
);
+
}
+
+
Widget _buildHeader({
+
required FeedType feedType,
+
required bool isAuthenticated,
+
}) {
+
return Container(
+
height: _kHeaderHeight,
+
decoration: BoxDecoration(
+
// Gradient fade from solid to transparent
+
gradient: LinearGradient(
+
begin: Alignment.topCenter,
+
end: Alignment.bottomCenter,
+
colors: [
+
AppColors.background,
+
AppColors.background.withValues(alpha: 0.8),
+
AppColors.background.withValues(alpha: 0),
+
],
+
stops: const [0.0, 0.6, 1.0],
+
),
+
),
+
padding: const EdgeInsets.symmetric(horizontal: 16),
+
child: Row(
+
children: [
+
// Feed type tabs in the center
+
Expanded(
+
child: _buildFeedTypeTabs(
+
feedType: feedType,
+
isAuthenticated: isAuthenticated,
+
),
+
),
+
// Search/Communities icon on the right
+
if (widget.onSearchTap != null)
+
Semantics(
+
label: 'Navigate to Communities',
+
button: true,
+
child: InkWell(
+
onTap: widget.onSearchTap,
+
borderRadius: BorderRadius.circular(20),
+
splashColor: AppColors.primary.withValues(alpha: 0.2),
+
child: Padding(
+
padding: const EdgeInsets.all(8),
+
child: BlueSkyIcon.search(color: AppColors.textPrimary),
+
),
+
),
+
),
+
],
+
),
+
);
+
}
+
+
Widget _buildFeedTypeTabs({
+
required FeedType feedType,
+
required bool isAuthenticated,
+
}) {
+
// If not authenticated, only show Discover
+
if (!isAuthenticated) {
+
return Center(
+
child: _buildFeedTypeTab(
+
label: 'Discover',
+
isActive: true,
+
onTap: null,
+
),
+
);
+
}
+
+
// Authenticated: show both tabs side by side (TikTok style)
+
return Row(
+
mainAxisAlignment: MainAxisAlignment.center,
+
children: [
+
_buildFeedTypeTab(
+
label: 'Discover',
+
isActive: feedType == FeedType.discover,
+
onTap: () => _switchToFeedType(FeedType.discover),
+
),
+
const SizedBox(width: 24),
+
_buildFeedTypeTab(
+
label: 'For You',
+
isActive: feedType == FeedType.forYou,
+
onTap: () => _switchToFeedType(FeedType.forYou),
+
),
+
],
+
);
+
}
+
+
Widget _buildFeedTypeTab({
+
required String label,
+
required bool isActive,
+
required VoidCallback? onTap,
+
}) {
+
return Semantics(
+
label: '$label feed${isActive ? ', selected' : ''}',
+
button: true,
+
selected: isActive,
+
child: GestureDetector(
+
onTap: onTap,
+
behavior: HitTestBehavior.opaque,
+
child: Column(
+
mainAxisSize: MainAxisSize.min,
+
mainAxisAlignment: MainAxisAlignment.center,
+
children: [
+
Text(
+
label,
+
style: TextStyle(
+
color: isActive
+
? AppColors.textPrimary
+
: AppColors.textSecondary.withValues(alpha: 0.6),
+
fontSize: 16,
+
fontWeight: isActive ? FontWeight.w700 : FontWeight.w400,
+
),
+
),
+
const SizedBox(height: 2),
+
// Underline indicator (TikTok style)
+
Container(
+
width: _kTabUnderlineWidth,
+
height: _kTabUnderlineHeight,
+
decoration: BoxDecoration(
+
color: isActive ? AppColors.textPrimary : Colors.transparent,
+
borderRadius: BorderRadius.circular(2),
+
),
+
),
+
],
),
),
);
}
+
void _switchToFeedType(FeedType type) {
+
Provider.of<FeedProvider>(context, listen: false).setFeedType(type);
+
}
+
Widget _buildBody({
required bool isLoading,
required String? error,
···
color: AppColors.primary,
child: ListView.builder(
controller: _scrollController,
+
// Add top padding so content isn't hidden behind transparent header
+
padding: const EdgeInsets.only(top: _kHeaderContentPadding),
// Add extra item for loading indicator or pagination error
itemCount: posts.length + (isLoadingMore || error != null ? 1 : 0),
itemBuilder: (context, index) {
+24 -13
lib/screens/home/main_shell_screen.dart
···
import '../../constants/app_colors.dart';
import '../../widgets/icons/bluesky_icons.dart';
+
import 'communities_screen.dart';
import 'create_post_screen.dart';
import 'feed_screen.dart';
import 'notifications_screen.dart';
import 'profile_screen.dart';
-
import 'search_screen.dart';
class MainShellScreen extends StatefulWidget {
const MainShellScreen({super.key});
···
class _MainShellScreenState extends State<MainShellScreen> {
int _selectedIndex = 0;
-
static const List<Widget> _screens = [
-
FeedScreen(),
-
SearchScreen(),
-
CreatePostScreen(),
-
NotificationsScreen(),
-
ProfileScreen(),
-
];
-
void _onItemTapped(int index) {
setState(() {
_selectedIndex = index;
});
}
+
void _onCommunitiesTap() {
+
setState(() {
+
_selectedIndex = 1; // Switch to communities tab
+
});
+
}
+
@override
Widget build(BuildContext context) {
return Scaffold(
-
body: _screens[_selectedIndex],
+
body: IndexedStack(
+
index: _selectedIndex,
+
children: [
+
FeedScreen(onSearchTap: _onCommunitiesTap),
+
const CommunitiesScreen(),
+
const CreatePostScreen(),
+
const NotificationsScreen(),
+
const ProfileScreen(),
+
],
+
),
bottomNavigationBar: Container(
decoration: const BoxDecoration(
color: Color(0xFF0B0F14),
···
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildNavItem(0, 'home', 'Home'),
-
_buildNavItem(1, 'search', 'Search'),
+
_buildNavItem(1, 'communities', 'Communities'),
_buildNavItem(2, 'plus', 'Create'),
_buildNavItem(3, 'bell', 'Notifications'),
_buildNavItem(4, 'person', 'Me'),
···
case 'home':
icon = BlueSkyIcon.homeSimple(color: color);
break;
-
case 'search':
-
icon = BlueSkyIcon.search(color: color);
+
case 'communities':
+
icon = Icon(
+
isSelected ? Icons.workspaces : Icons.workspaces_outlined,
+
color: color,
+
size: 24,
+
);
break;
case 'plus':
icon = BlueSkyIcon.plus(color: color);
+117 -67
lib/widgets/comment_card.dart
···
/// - Heart vote button with optimistic updates via VoteProvider
/// - Visual threading indicator based on nesting depth
/// - Tap-to-reply functionality via [onTap] callback
+
/// - Long-press to collapse thread via [onLongPress] callback
///
/// The [currentTime] parameter allows passing the current time for
/// time-ago calculations, enabling periodic updates and testing.
+
///
+
/// When [isCollapsed] is true, displays a badge showing [collapsedCount]
+
/// hidden replies on the threading indicator bar.
class CommentCard extends StatelessWidget {
const CommentCard({
required this.comment,
this.depth = 0,
this.currentTime,
this.onTap,
+
this.onLongPress,
+
this.isCollapsed = false,
+
this.collapsedCount = 0,
super.key,
});
···
/// Callback when the comment is tapped (for reply functionality)
final VoidCallback? onTap;
+
/// Callback when the comment is long-pressed (for collapse functionality)
+
final VoidCallback? onLongPress;
+
+
/// Whether this comment's thread is currently collapsed
+
final bool isCollapsed;
+
+
/// Number of replies hidden when collapsed
+
final int collapsedCount;
+
@override
Widget build(BuildContext context) {
// All comments get at least 1 threading line (depth + 1)
···
// the stroke width)
final borderLeftOffset = (threadingLineCount * 6.0) + 2.0;
-
return InkWell(
-
onTap: onTap,
-
child: Container(
-
decoration: const BoxDecoration(color: AppColors.background),
-
child: Stack(
-
children: [
-
// Threading indicators - vertical lines showing nesting ancestry
-
Positioned.fill(
-
child: CustomPaint(
-
painter: _CommentDepthPainter(depth: threadingLineCount),
+
return GestureDetector(
+
onLongPress: onLongPress != null
+
? () {
+
HapticFeedback.mediumImpact();
+
onLongPress!();
+
}
+
: null,
+
child: InkWell(
+
onTap: onTap,
+
child: Container(
+
decoration: const BoxDecoration(color: AppColors.background),
+
child: Stack(
+
children: [
+
// Threading indicators - vertical lines showing nesting ancestry
+
Positioned.fill(
+
child: CustomPaint(
+
painter: _CommentDepthPainter(depth: threadingLineCount),
+
),
),
-
),
-
// Bottom border (starts after threading lines, not overlapping them)
-
Positioned(
-
left: borderLeftOffset,
-
right: 0,
-
bottom: 0,
-
child: Container(height: 1, color: AppColors.border),
-
),
-
// Comment content with depth-based left padding
-
Padding(
-
padding: EdgeInsets.fromLTRB(leftPadding, 12, 16, 8),
-
child: Column(
-
crossAxisAlignment: CrossAxisAlignment.start,
-
children: [
-
// Author info row
-
Row(
-
children: [
-
// Author avatar
-
_buildAuthorAvatar(comment.author),
-
const SizedBox(width: 8),
-
Expanded(
-
child: Column(
-
crossAxisAlignment: CrossAxisAlignment.start,
-
children: [
-
// Author handle
-
Text(
-
'@${comment.author.handle}',
-
style: TextStyle(
-
color: AppColors.textPrimary.withValues(
-
alpha: 0.5,
+
// Collapsed count badge - positioned after threading lines
+
// to avoid overlap at any depth level
+
if (isCollapsed && collapsedCount > 0)
+
Positioned(
+
left: borderLeftOffset + 4,
+
bottom: 8,
+
child: Container(
+
padding: const EdgeInsets.symmetric(
+
horizontal: 6,
+
vertical: 2,
+
),
+
decoration: BoxDecoration(
+
color: AppColors.primary,
+
borderRadius: BorderRadius.circular(8),
+
),
+
child: Text(
+
'+$collapsedCount hidden',
+
style: const TextStyle(
+
color: AppColors.textPrimary,
+
fontSize: 10,
+
fontWeight: FontWeight.w500,
+
),
+
),
+
),
+
),
+
// Bottom border
+
// (starts after threading lines, not overlapping them)
+
Positioned(
+
left: borderLeftOffset,
+
right: 0,
+
bottom: 0,
+
child: Container(height: 1, color: AppColors.border),
+
),
+
// Comment content with depth-based left padding
+
Padding(
+
padding: EdgeInsets.fromLTRB(leftPadding, 12, 16, 8),
+
child: Column(
+
crossAxisAlignment: CrossAxisAlignment.start,
+
children: [
+
// Author info row
+
Row(
+
children: [
+
// Author avatar
+
_buildAuthorAvatar(comment.author),
+
const SizedBox(width: 8),
+
Expanded(
+
child: Column(
+
crossAxisAlignment: CrossAxisAlignment.start,
+
children: [
+
// Author handle
+
Text(
+
'@${comment.author.handle}',
+
style: TextStyle(
+
color: AppColors.textPrimary.withValues(
+
alpha: 0.5,
+
),
+
fontSize: 13,
+
fontWeight: FontWeight.w500,
),
-
fontSize: 13,
-
fontWeight: FontWeight.w500,
),
-
),
-
],
-
),
-
),
-
// Time ago
-
Text(
-
DateTimeUtils.formatTimeAgo(
-
comment.createdAt,
-
currentTime: currentTime,
+
],
+
),
),
-
style: TextStyle(
-
color: AppColors.textPrimary.withValues(alpha: 0.5),
-
fontSize: 12,
+
// Time ago
+
Text(
+
DateTimeUtils.formatTimeAgo(
+
comment.createdAt,
+
currentTime: currentTime,
+
),
+
style: TextStyle(
+
color: AppColors.textPrimary.withValues(alpha: 0.5),
+
fontSize: 12,
+
),
),
-
),
+
],
+
),
+
const SizedBox(height: 8),
+
+
// Comment content
+
if (comment.content.isNotEmpty) ...[
+
_buildCommentContent(comment),
+
const SizedBox(height: 8),
],
-
),
-
const SizedBox(height: 8),
-
// Comment content
-
if (comment.content.isNotEmpty) ...[
-
_buildCommentContent(comment),
-
const SizedBox(height: 8),
+
// Action buttons (just vote for now)
+
_buildActionButtons(context),
],
-
-
// Action buttons (just vote for now)
-
_buildActionButtons(context),
-
],
+
),
),
-
),
-
],
+
],
+
),
),
),
);
+3
.gitignore
···
/android/app/debug
/android/app/profile
/android/app/release
+
+
# macOS (not targeting this platform)
+
macos/
+1
ios/Flutter/Debug.xcconfig
···
+
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
#include "Generated.xcconfig"
+1
ios/Flutter/Release.xcconfig
···
+
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
#include "Generated.xcconfig"
+43
ios/Podfile
···
+
# Uncomment this line to define a global platform for your project
+
# platform :ios, '13.0'
+
+
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
+
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
+
+
project 'Runner', {
+
'Debug' => :debug,
+
'Profile' => :release,
+
'Release' => :release,
+
}
+
+
def flutter_root
+
generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__)
+
unless File.exist?(generated_xcode_build_settings_path)
+
raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first"
+
end
+
+
File.foreach(generated_xcode_build_settings_path) do |line|
+
matches = line.match(/FLUTTER_ROOT\=(.*)/)
+
return matches[1].strip if matches
+
end
+
raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get"
+
end
+
+
require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)
+
+
flutter_ios_podfile_setup
+
+
target 'Runner' do
+
use_frameworks!
+
+
flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
+
target 'RunnerTests' do
+
inherit! :search_paths
+
end
+
end
+
+
post_install do |installer|
+
installer.pods_project.targets.each do |target|
+
flutter_additional_ios_build_settings(target)
+
end
+
end
+4 -4
pubspec.lock
···
dependency: transitive
description:
name: meta
-
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
+
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.dev"
source: hosted
-
version: "1.16.0"
+
version: "1.17.0"
mime:
dependency: transitive
description:
···
dependency: transitive
description:
name: test_api
-
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
+
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
url: "https://pub.dev"
source: hosted
-
version: "0.7.6"
+
version: "0.7.7"
typed_data:
dependency: transitive
description:
+1 -1
ios/Flutter/AppFrameworkInfo.plist
···
<key>CFBundleVersion</key>
<string>1.0</string>
<key>MinimumOSVersion</key>
-
<string>12.0</string>
+
<string>13.0</string>
</dict>
</plist>
+68
ios/Podfile.lock
···
+
PODS:
+
- Flutter (1.0.0)
+
- flutter_secure_storage (6.0.0):
+
- Flutter
+
- flutter_web_auth_2 (3.0.0):
+
- Flutter
+
- path_provider_foundation (0.0.1):
+
- Flutter
+
- FlutterMacOS
+
- share_plus (0.0.1):
+
- Flutter
+
- shared_preferences_foundation (0.0.1):
+
- Flutter
+
- FlutterMacOS
+
- sqflite_darwin (0.0.4):
+
- Flutter
+
- FlutterMacOS
+
- url_launcher_ios (0.0.1):
+
- Flutter
+
- video_player_avfoundation (0.0.1):
+
- Flutter
+
- FlutterMacOS
+
+
DEPENDENCIES:
+
- Flutter (from `Flutter`)
+
- flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
+
- flutter_web_auth_2 (from `.symlinks/plugins/flutter_web_auth_2/ios`)
+
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
+
- share_plus (from `.symlinks/plugins/share_plus/ios`)
+
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
+
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
+
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
+
- video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/darwin`)
+
+
EXTERNAL SOURCES:
+
Flutter:
+
:path: Flutter
+
flutter_secure_storage:
+
:path: ".symlinks/plugins/flutter_secure_storage/ios"
+
flutter_web_auth_2:
+
:path: ".symlinks/plugins/flutter_web_auth_2/ios"
+
path_provider_foundation:
+
:path: ".symlinks/plugins/path_provider_foundation/darwin"
+
share_plus:
+
:path: ".symlinks/plugins/share_plus/ios"
+
shared_preferences_foundation:
+
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
+
sqflite_darwin:
+
:path: ".symlinks/plugins/sqflite_darwin/darwin"
+
url_launcher_ios:
+
:path: ".symlinks/plugins/url_launcher_ios/ios"
+
video_player_avfoundation:
+
:path: ".symlinks/plugins/video_player_avfoundation/darwin"
+
+
SPEC CHECKSUMS:
+
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
+
flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13
+
flutter_web_auth_2: 5c8d9dcd7848b5a9efb086d24e7a9adcae979c80
+
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
+
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
+
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
+
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
+
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
+
video_player_avfoundation: dd410b52df6d2466a42d28550e33e4146928280a
+
+
PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e
+
+
COCOAPODS: 1.16.2
+2
ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
···
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
+
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
shouldUseLaunchSchemeArgsEnv = "YES">
<MacroExpansion>
<BuildableReference
···
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
+
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
+3
ios/Runner.xcworkspace/contents.xcworkspacedata
···
<FileRef
location = "group:Runner.xcodeproj">
</FileRef>
+
<FileRef
+
location = "group:Pods/Pods.xcodeproj">
+
</FileRef>
</Workspace>