From 59aa865bf255196abe41efed5909bc30179e9416 Mon Sep 17 00:00:00 2001 From: Roscoe Rubin-Rottenberg Date: Wed, 22 Apr 2026 21:43:50 -0400 Subject: [PATCH] refactor: prepare for auth scopes --- .../core/auth/data/models/auth_snapshot.dart | 4 + .../repositories/auth_repository_impl.dart | 27 +++++-- lib/src/core/config/app_config.dart | 28 +++++-- .../data/repositories/sprk_repository.dart | 6 +- .../repositories/sprk_repository_impl.dart | 16 +++- .../auth_repository_impl_test.dart | 78 +++++++++++++++++++ 6 files changed, 138 insertions(+), 21 deletions(-) diff --git a/lib/src/core/auth/data/models/auth_snapshot.dart b/lib/src/core/auth/data/models/auth_snapshot.dart index ee29cee..3b6ff8d 100644 --- a/lib/src/core/auth/data/models/auth_snapshot.dart +++ b/lib/src/core/auth/data/models/auth_snapshot.dart @@ -89,6 +89,7 @@ class AipClientRegistration { this.clientSecret, this.registrationAccessToken, this.clientSecretExpiresAt, + this.scope, }); factory AipClientRegistration.fromJson(Map json) { @@ -97,6 +98,7 @@ class AipClientRegistration { clientSecret: json['clientSecret'] as String?, registrationAccessToken: json['registrationAccessToken'] as String?, clientSecretExpiresAt: json['clientSecretExpiresAt'] as String?, + scope: json['scope'] as String?, ); } @@ -104,6 +106,7 @@ class AipClientRegistration { final String? clientSecret; final String? registrationAccessToken; final String? clientSecretExpiresAt; + final String? scope; DateTime? get clientSecretExpiresAtDateTime { final value = clientSecretExpiresAt; @@ -117,6 +120,7 @@ class AipClientRegistration { 'clientSecret': clientSecret, 'registrationAccessToken': registrationAccessToken, 'clientSecretExpiresAt': clientSecretExpiresAt, + 'scope': scope, }; } } diff --git a/lib/src/core/auth/data/repositories/auth_repository_impl.dart b/lib/src/core/auth/data/repositories/auth_repository_impl.dart index bee2140..971ccc0 100644 --- a/lib/src/core/auth/data/repositories/auth_repository_impl.dart +++ b/lib/src/core/auth/data/repositories/auth_repository_impl.dart @@ -20,7 +20,6 @@ typedef AtprotoSessionFetcher = Future<({String did, String handle})> Function(ATProto atproto); const String _redirectUriValue = 'sprk://oauth-callback'; -const String _aipScope = 'atproto transition:generic'; const String _clientName = 'Spark Mobile App'; const String _clientUri = 'https://sprk.so'; const String _softwareId = 'spark-mobile'; @@ -29,6 +28,16 @@ const Duration _refreshLeeway = Duration(minutes: 5); const String _randomCharset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~'; +List _buildAipScopes() { + return const ['atproto', 'transition:generic']; +} + +String _buildAipScope() => _buildAipScopes().join(' '); + +bool _registrationScopeMatches(AipClientRegistration registration) { + return registration.scope == _buildAipScope(); +} + class AuthRepositoryImpl implements AuthRepository { AuthRepositoryImpl({ LocalStorageInterface? secureStorage, @@ -51,6 +60,7 @@ class AuthRepositoryImpl implements AuthRepository { final DateTime Function() _now; final AtprotoSessionFetcher _fetchSessionInfo; final Uri _aipBaseUri; + final List _aipScopes = _buildAipScopes(); final Completer _initCompleter = Completer(); Future? _refreshInFlight; @@ -336,7 +346,9 @@ class AuthRepositoryImpl implements AuthRepository { _AipOAuthMetadata metadata, ) async { final existing = _snapshot?.aipClientRegistration; - if (existing != null && !_registrationNeedsRefresh(existing)) { + if (existing != null && + !_registrationNeedsRefresh(existing) && + _registrationScopeMatches(existing)) { return existing; } @@ -350,7 +362,7 @@ class AuthRepositoryImpl implements AuthRepository { 'response_types': ['code'], 'grant_types': ['authorization_code', 'refresh_token'], 'token_endpoint_auth_method': 'client_secret_post', - 'scope': _aipScope, + 'scope': _buildAipScope(), 'software_id': _softwareId, 'software_version': _softwareVersion, }), @@ -364,7 +376,7 @@ class AuthRepositoryImpl implements AuthRepository { final registration = _AipClientRegistrationResponse.fromJson( _decodeJsonObject(response.body), - ).toStoredRegistration(); + ).toStoredRegistration(scope: _buildAipScope()); final previousClientId = existing?.clientId; _snapshot = (_snapshot ?? const AuthSnapshot()).copyWith( @@ -448,7 +460,7 @@ class AuthRepositoryImpl implements AuthRepository { var authorizationUri = grant.getAuthorizationUrl( redirectUri, - scopes: _aipScope.split(' '), + scopes: _aipScopes, state: state, ); @@ -510,7 +522,7 @@ class AuthRepositoryImpl implements AuthRepository { grant.getAuthorizationUrl( Uri.parse(context.redirectUri), - scopes: _aipScope.split(' '), + scopes: _aipScopes, state: context.state, ); @@ -903,7 +915,7 @@ class _AipClientRegistrationResponse { final String? registrationAccessToken; final int? clientSecretExpiresAt; - AipClientRegistration toStoredRegistration() { + AipClientRegistration toStoredRegistration({required String scope}) { final secretExpiry = clientSecretExpiresAt; final expiryDateTime = secretExpiry == null || secretExpiry <= 0 ? null @@ -914,6 +926,7 @@ class _AipClientRegistrationResponse { clientSecret: clientSecret, registrationAccessToken: registrationAccessToken, clientSecretExpiresAt: expiryDateTime?.toIso8601String(), + scope: scope, ); } } diff --git a/lib/src/core/config/app_config.dart b/lib/src/core/config/app_config.dart index 3f8c4aa..256bee6 100644 --- a/lib/src/core/config/app_config.dart +++ b/lib/src/core/config/app_config.dart @@ -8,24 +8,38 @@ import 'package:flutter_dotenv/flutter_dotenv.dart'; class AppConfig { /// Base URL for the video processing service. static String get videoServiceUrl => - _getStringValue('VIDEO_SERVICE_URL', 'http://localhost:3000'); + _getStringValue('VIDEO_SERVICE_URL', 'https://video.sprk.so'); /// License key for the img.ly editor. static String get license => _getStringValue('SHOWCASES_LICENSE_FLUTTER', ''); /// URL for the app view (web view display). static String get appViewUrl => - _getStringValue('SPRK_APPVIEW_URL', 'http://localhost:3000'); + _getStringValue('SPRK_APPVIEW_URL', 'https://api.sprk.so'); + + /// Base URL for the Bluesky appview. + static String get bskyAppViewUrl => + _getStringValue('BSKY_APPVIEW_URL', 'https://api.bsky.app'); + + /// DID for the Spark moderation service. + static String get modDid => _getStringValue( + 'MOD_DID', + 'did:plc:pbgyr67hftvpoqtvaurpsctc#atproto_labeler', + ); + + /// DID for the Bluesky moderation service. + static String get bskyModDid => _getStringValue( + 'BSKY_MOD_DID', + 'did:plc:ar7c4by46qjdydhdevvrndac#atproto_labeler', + ); /// Base URL for the messages service (chat service). static String get messagesServiceUrl => - _getStringValue('MESSAGES_SERVICE_URL', 'http://localhost:3000'); + _getStringValue('MESSAGES_SERVICE_URL', 'https://chat.sprk.so'); /// Base URL for the AIP OAuth server. - static String get aipBaseUrl => _getStringValue( - 'AIP_BASE_URL', - _getStringValue('OAUTH_ISSUER_URL', 'https://auth.sprk.so'), - ); + static String get aipBaseUrl => + _getStringValue('AIP_BASE_URL', 'https://auth.sprk.so'); /// Service DID for the chat service (used for service auth). static String get chatServiceDid => diff --git a/lib/src/core/network/atproto/data/repositories/sprk_repository.dart b/lib/src/core/network/atproto/data/repositories/sprk_repository.dart index d9a66c1..ab6f423 100644 --- a/lib/src/core/network/atproto/data/repositories/sprk_repository.dart +++ b/lib/src/core/network/atproto/data/repositories/sprk_repository.dart @@ -21,9 +21,9 @@ abstract class SprkRepository { /// Get the Sprk DID String get sprkDid; - String get bskyDid => 'did:web:api.bsky.app#bsky_appview'; - String get modDid => 'did:plc:pbgyr67hftvpoqtvaurpsctc#atproto_labeler'; - String get bskyModDid => 'did:plc:ar7c4by46qjdydhdevvrndac#atproto_labeler'; + String get bskyDid; + String get modDid; + String get bskyModDid; ActorRepository get actor; RepoRepository get repo; diff --git a/lib/src/core/network/atproto/data/repositories/sprk_repository_impl.dart b/lib/src/core/network/atproto/data/repositories/sprk_repository_impl.dart index 2493a0d..57e5a42 100644 --- a/lib/src/core/network/atproto/data/repositories/sprk_repository_impl.dart +++ b/lib/src/core/network/atproto/data/repositories/sprk_repository_impl.dart @@ -24,11 +24,14 @@ import 'package:spark/src/core/utils/logging/logger.dart'; /// Client for interacting with Spark API endpoints class SprkRepositoryImpl implements SprkRepository { - SprkRepositoryImpl(this._authRepository) : _sprkDid = _getSprkDid() { + SprkRepositoryImpl(this._authRepository) + : _sprkDid = _getSprkDid(), + _bskyDid = _getBskyDid() { _logger.d('SprkRepository initialized with DID: $_sprkDid'); } final AuthRepository _authRepository; final String _sprkDid; + final String _bskyDid; final SparkLogger _logger = GetIt.instance().getLogger( 'SprkRepository', ); @@ -52,19 +55,24 @@ class SprkRepositoryImpl implements SprkRepository { String get sprkDid => _sprkDid; @override - String get bskyDid => 'did:web:api.bsky.app#bsky_appview'; + String get bskyDid => _bskyDid; @override - String get modDid => 'did:plc:pbgyr67hftvpoqtvaurpsctc#atproto_labeler'; + String get modDid => AppConfig.modDid; @override - String get bskyModDid => 'did:plc:ar7c4by46qjdydhdevvrndac#atproto_labeler'; + String get bskyModDid => AppConfig.bskyModDid; static String _getSprkDid() { final sprkAppView = Uri.parse(AppConfig.appViewUrl); return 'did:web:${sprkAppView.host}#sprk_appview'; } + static String _getBskyDid() { + final bskyAppView = Uri.parse(AppConfig.bskyAppViewUrl); + return 'did:web:${bskyAppView.host}#bsky_appview'; + } + /// Execute API request with token expiration handling /// This method performs a single retry after refreshing the token. /// To prevent infinite loops, it does NOT call executeWithRetry recursively. diff --git a/test/src/core/auth/data/repositories/auth_repository_impl_test.dart b/test/src/core/auth/data/repositories/auth_repository_impl_test.dart index 4402b31..4756ae7 100644 --- a/test/src/core/auth/data/repositories/auth_repository_impl_test.dart +++ b/test/src/core/auth/data/repositories/auth_repository_impl_test.dart @@ -519,6 +519,10 @@ void main() { registrationBody['grant_types'], containsAll(['authorization_code', 'refresh_token']), ); + expect( + registrationBody['scope'] as String, + 'atproto transition:generic', + ); return http.Response( json.encode({ 'client_id': 'client-1', @@ -565,6 +569,7 @@ void main() { expect(authUri.queryParameters['login_hint'], 'alice.sprk.so'); expect(authUri.queryParameters['code_challenge'], isNotEmpty); expect(authUri.queryParameters['state'], isNotEmpty); + expect(authUri.queryParameters['scope'], 'atproto transition:generic'); final callbackUrl = Uri.parse(_redirectUri) .replace( @@ -584,6 +589,79 @@ void main() { }, ); + test( + 'initiateOAuth re-registers when the cached AIP client scope is stale', + () async { + final storage = _InMemoryStorage(); + await _storeSnapshot( + storage, + AuthSnapshot( + aipClientRegistration: const AipClientRegistration( + clientId: 'stale-client', + clientSecret: 'secret-1', + scope: 'atproto', + ), + ), + ); + + var registrationCalls = 0; + final client = MockClient((request) async { + switch (request.url.path) { + case '/.well-known/oauth-authorization-server': + return http.Response( + json.encode({ + 'authorization_endpoint': + 'https://auth.sprk.so/oauth/authorize', + 'token_endpoint': 'https://auth.sprk.so/oauth/token', + 'registration_endpoint': + 'https://auth.sprk.so/oauth/clients/register', + }), + 200, + ); + case '/oauth/clients/register': + registrationCalls += 1; + final registrationBody = + json.decode(request.body) as Map; + expect( + registrationBody['scope'] as String, + 'atproto transition:generic', + ); + return http.Response( + json.encode({ + 'client_id': 'client-2', + 'client_secret': 'secret-2', + }), + 201, + ); + default: + return http.Response('unexpected request', 500); + } + }); + + final repository = AuthRepositoryImpl( + secureStorage: storage, + httpClient: client, + logger: SparkLogger(name: 'AuthRepositoryTest'), + ); + + await repository.initializationComplete; + final authUrl = await repository.initiateOAuth('alice.sprk.so'); + final authUri = Uri.parse(authUrl); + + expect(registrationCalls, 1); + expect(authUri.queryParameters['scope'], 'atproto transition:generic'); + + final savedSnapshot = AuthSnapshot.fromJsonString( + (await storage.getString(StorageKeys.account))!, + ); + expect(savedSnapshot.aipClientRegistration?.clientId, 'client-2'); + expect( + savedSnapshot.aipClientRegistration?.scope, + 'atproto transition:generic', + ); + }, + ); + test('initiateOAuthWithService omits login_hint in AIP mode', () async { final storage = _InMemoryStorage(); final client = MockClient((request) async { -- 2.51.2