From f6fc351c73df0f79eb309727e076121403a919ac Mon Sep 17 00:00:00 2001 From: The one with the braid Date: Fri, 7 Feb 2025 19:13:46 +0100 Subject: [PATCH 01/17] BREAKING: implement Native OIDC as per MSC 3861 Implements: - MSC 3861 - Next-generation auth for Matrix, based on OAuth 2.0/OIDC - MSC 1597 - Better spec for matrix identifiers - MSC 2964 - Usage of OAuth 2.0 authorization code grant and refresh token grant - MSC 2965 - OAuth 2.0 Authorization Server Metadata discovery - MSC 2966 - Usage of OAuth 2.0 Dynamic Client Registration in Matrix - MSC 2967 - API scopes - MSC 3824 - OIDC aware clients - MSC 4191 - Account management deep-linking Signed-off-by: The one with the braid --- lib/matrix.dart | 1 + lib/msc_extensions/README.md | 10 +- .../msc1597_matrix_identifier_syntax.dart | 31 ++ .../msc2964_oidc_oauth_grants.dart | 305 ++++++++++++++++++ .../msc2965_oidc_auth_metadata.dart | 84 +++++ ...2966_oidc_dynamic_client_registration.dart | 194 +++++++++++ .../msc3824_oidc_delegation.dart | 8 + .../msc4191_account_management.dart | 44 +++ .../msc_3861_native_oidc.dart | 22 ++ lib/src/client.dart | 87 ++++- lib/src/database/database_api.dart | 14 +- .../database/hive_collections_database.dart | 58 +++- lib/src/database/matrix_sdk_database.dart | 63 +++- test/database_api_test.dart | 2 - test/matrix_database_test.dart | 1 - 15 files changed, 875 insertions(+), 49 deletions(-) create mode 100644 lib/msc_extensions/msc_3861_native_oidc/msc1597_matrix_identifier_syntax/msc1597_matrix_identifier_syntax.dart create mode 100644 lib/msc_extensions/msc_3861_native_oidc/msc2964_oidc_oauth_grants/msc2964_oidc_oauth_grants.dart create mode 100644 lib/msc_extensions/msc_3861_native_oidc/msc2965_oidc_auth_metadata/msc2965_oidc_auth_metadata.dart create mode 100644 lib/msc_extensions/msc_3861_native_oidc/msc2966_oidc_dynamic_client_registration/msc2966_oidc_dynamic_client_registration.dart create mode 100644 lib/msc_extensions/msc_3861_native_oidc/msc3824_oidc_delegation/msc3824_oidc_delegation.dart create mode 100644 lib/msc_extensions/msc_3861_native_oidc/msc4191_account_management/msc4191_account_management.dart create mode 100644 lib/msc_extensions/msc_3861_native_oidc/msc_3861_native_oidc.dart diff --git a/lib/matrix.dart b/lib/matrix.dart index c3219e840..bf7584707 100644 --- a/lib/matrix.dart +++ b/lib/matrix.dart @@ -78,6 +78,7 @@ export 'msc_extensions/extension_recent_emoji/recent_emoji.dart'; export 'msc_extensions/msc_3935_cute_events/msc_3935_cute_events.dart'; export 'msc_extensions/msc_1236_widgets/msc_1236_widgets.dart'; export 'msc_extensions/msc_2835_uia_login/msc_2835_uia_login.dart'; +export 'msc_extensions/msc_3861_native_oidc/msc_3861_native_oidc.dart'; export 'msc_extensions/msc_3814_dehydrated_devices/msc_3814_dehydrated_devices.dart'; export 'src/utils/web_worker/web_worker_stub.dart' diff --git a/lib/msc_extensions/README.md b/lib/msc_extensions/README.md index 1fa96848e..e9d0bd60e 100644 --- a/lib/msc_extensions/README.md +++ b/lib/msc_extensions/README.md @@ -20,5 +20,13 @@ Please try to cover the following conventions: - MSC 1236 - Widget API V2 - MSC 2835 - UIA login - MSC 3814 - Dehydrated Devices +- MSC 3861 - Next-generation auth for Matrix, based on OAuth 2.0/OIDC + - MSC 1597 - Better spec for matrix identifiers + - MSC 2964 - Usage of OAuth 2.0 authorization code grant and refresh token grant + - MSC 2965 - OAuth 2.0 Authorization Server Metadata discovery + - MSC 2966 - Usage of OAuth 2.0 Dynamic Client Registration in Matrix + - MSC 2967 - API scopes + - MSC 3824 - OIDC aware clients + - MSC 4191 - Account management deep-linking - MSC 3935 - Cute Events -- `io.element.recent_emoji` - recent emoji sync in account data \ No newline at end of file +- `io.element.recent_emoji` - recent emoji sync in account data diff --git a/lib/msc_extensions/msc_3861_native_oidc/msc1597_matrix_identifier_syntax/msc1597_matrix_identifier_syntax.dart b/lib/msc_extensions/msc_3861_native_oidc/msc1597_matrix_identifier_syntax/msc1597_matrix_identifier_syntax.dart new file mode 100644 index 000000000..a230cd686 --- /dev/null +++ b/lib/msc_extensions/msc_3861_native_oidc/msc1597_matrix_identifier_syntax/msc1597_matrix_identifier_syntax.dart @@ -0,0 +1,31 @@ +import 'dart:math'; + +import 'package:matrix/matrix.dart'; + +extension GenerateDeviceIdExtension on Client { + /// MSC 2964 & MSC 2967 + Future oidcEnsureDeviceId([bool enforceNewDevice = false]) async { + if (!enforceNewDevice) { + final storedDeviceId = await database?.getDeviceId(); + if (storedDeviceId is String) { + Logs().d('[OIDC] Restoring device ID $storedDeviceId.'); + return storedDeviceId; + } + } + + // MSC 1597 + // + // [A-Z] but without I and O (smth too similar to 1 and 0) + const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ'; + final deviceId = String.fromCharCodes( + List.generate( + 10, + (_) => chars.codeUnitAt(Random().nextInt(chars.length)), + ), + ); + + await database?.storeDeviceId(deviceId); + Logs().d('[OIDC] Generated device ID $deviceId.'); + return deviceId; + } +} diff --git a/lib/msc_extensions/msc_3861_native_oidc/msc2964_oidc_oauth_grants/msc2964_oidc_oauth_grants.dart b/lib/msc_extensions/msc_3861_native_oidc/msc2964_oidc_oauth_grants/msc2964_oidc_oauth_grants.dart new file mode 100644 index 000000000..5ff7f1aa5 --- /dev/null +++ b/lib/msc_extensions/msc_3861_native_oidc/msc2964_oidc_oauth_grants/msc2964_oidc_oauth_grants.dart @@ -0,0 +1,305 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; + +import 'package:http/http.dart' hide Client; + +import 'package:matrix/matrix.dart'; +import 'package:matrix/src/utils/crypto/crypto.dart'; + +extension OidcOauthGrantFlowExtension on Client { + Future oidcAuthorizationGrantFlow({ + required Completer nativeCompleter, + required String oidcClientId, + required String responseType, + required Uri redirectUri, + required String responseMode, + String? initialDeviceDisplayName, + bool enforceNewDeviceId = false, + String? prompt, + void Function(InitState)? onInitStateChanged, + }) async { + final verifier = oidcGenerateUnreservedString(); + final state = oidcGenerateUnreservedString(); + + final deviceId = await oidcEnsureDeviceId(enforceNewDeviceId); + + await oidcAuthMetadataLoading; + + Uri authEndpoint; + Uri tokenEndpoint; + + try { + final authData = oidcAuthMetadata!; + authEndpoint = Uri.parse(authData['authorization_endpoint'] as String); + tokenEndpoint = Uri.parse(authData['token_endpoint'] as String); + // ensure we only hand over permitted prompts + if (prompt != null) { + final supported = authData['prompt_values_supported']; + if (supported is Iterable && !supported.contains(prompt)) { + prompt = null; + } + } + // we do not check the *_supported flags since we assume the homeserver + // is properly set up + // https://github.com/sandhose/matrix-spec-proposals/blob/msc/sandhose/oauth2-profile/proposals/2964-oauth2-profile.md#prerequisites + } catch (e, s) { + Logs().e('[OIDC] Auth Metadata not valid according to MSC2965.', e, s); + rethrow; + } + + // launch the OAuth2 request at the IDP + await oidcStartOAuth2( + authorizationEndpoint: authEndpoint, + oidcClientId: oidcClientId, + responseType: responseType, + redirectUri: redirectUri, + scope: [ + 'openid', + // 'urn:matrix:client:api:*', + 'urn:matrix:org.matrix.msc2967.client:api:*', + // 'urn:matrix:client:device:*', + 'urn:matrix:org.matrix.msc2967.client:device:$deviceId', + ], + responseMode: responseMode, + state: state, + codeVerifier: verifier, + prompt: prompt, + ); + + // wait for the matrix client to receive the redirect callback from the IDP + final nativeResponse = await nativeCompleter.future; + + // check whether the native redirect contains a successful state + final oAuth2Code = nativeResponse.code; + if (nativeResponse.error != null || oAuth2Code == null) { + Logs().e( + '[OIDC] OAuth2 error ${nativeResponse.error}: ${nativeResponse.errorDescription} - ${nativeResponse.errorUri}'); + throw nativeResponse; + } + + // exchange the OAuth2 code into a token + final oidcToken = await oidcRequestToken( + tokenEndpoint: tokenEndpoint, + oidcClientId: oidcClientId, + oAuth2Code: oAuth2Code, + redirectUri: redirectUri, + codeVerifier: verifier, + ); + + // figure out who we are + bearerToken = oidcToken.accessToken; + final matrixTokenInfo = await getTokenOwner(); + bearerToken = null; + + final homeserver = this.homeserver; + if (homeserver == null) { + throw Exception('OIDC flow successful but homeserver is null.'); + } + + final tokenExpiresAt = + DateTime.now().add(Duration(milliseconds: oidcToken.expiresIn)); + + await init( + newToken: oidcToken.accessToken, + newTokenExpiresAt: tokenExpiresAt, + newRefreshToken: oidcToken.refreshToken, + newUserID: matrixTokenInfo.userId, + newHomeserver: homeserver, + newDeviceName: initialDeviceDisplayName ?? '', + newDeviceID: matrixTokenInfo.deviceId, + onInitStateChanged: onInitStateChanged, + ); + } + + /// Starts an OAuth2 flow + /// + /// - generates the challenge for the `codeVerifier` as per RFC 7636 + /// - dispatches the request + /// + /// Parameters: https://github.com/sandhose/matrix-spec-proposals/blob/msc/sandhose/oauth2-profile/proposals/2964-oauth2-profile.md#flow-parameters + Future oidcStartOAuth2({ + required Uri authorizationEndpoint, + required String oidcClientId, + required String responseType, + required Uri redirectUri, + required List scope, + required String responseMode, + required String state, + required String codeVerifier, + String? prompt, + }) async { + // https://datatracker.ietf.org/doc/html/rfc7636#section-4.2 + final codeChallenge = await sha256.call(latin1.encode(codeVerifier)); + + final requestUri = authorizationEndpoint.replace( + queryParameters: { + 'client_id': oidcClientId, + 'response_type': responseType, + 'response_mode': responseMode, + 'redirect_uri': redirectUri.toString(), + 'scope': scope.join(' '), + if (prompt != null) 'prompt': prompt, + 'code_challenge': base64Encode(codeChallenge), + 'code_challenge_method': 'S256', + }, + ); + final request = Request('GET', requestUri); + request.headers['content-type'] = 'application/json'; + final response = await httpClient.send(request); + final responseBody = await response.stream.toBytes(); + if (response.statusCode != 200) { + unexpectedResponse(response, responseBody); + } + } + + /// Exchanges an OIDC OAuth2 code into an access token + /// + /// Reference: https://github.com/sandhose/matrix-spec-proposals/blob/msc/sandhose/oauth2-profile/proposals/2964-oauth2-profile.md#token-request + Future oidcRequestToken({ + required Uri tokenEndpoint, + required String oidcClientId, + required String oAuth2Code, + required Uri redirectUri, + required String codeVerifier, + }) async { + final request = Request('POST', tokenEndpoint); + request.bodyFields.addAll({ + 'grant_type': 'authorization_code', + 'code': oAuth2Code, + 'redirect_uri': redirectUri.toString(), + 'client_id': oidcClientId, + 'code_verifier': codeVerifier, + }); + request.headers['content-type'] = 'application/x-www-form-urlencoded'; + final response = await httpClient.send(request); + final responseBody = await response.stream.toBytes(); + if (response.statusCode != 200) { + unexpectedResponse(response, responseBody); + } + final responseString = utf8.decode(responseBody); + final json = jsonDecode(responseString); + return OidcTokenResponse.fromJson(json); + } + + /// Refreshes an OIDC refresh token + /// + /// Reference: https://github.com/sandhose/matrix-spec-proposals/blob/msc/sandhose/oauth2-profile/proposals/2964-oauth2-profile.md#token-refresh + Future oidcRefreshToken({ + required Uri tokenEndpoint, + required String refreshToken, + required String oidcClientId, + }) async { + final request = Request('POST', tokenEndpoint); + request.bodyFields.addAll({ + 'grant_type': 'refresh_token', + 'refresh_token': refreshToken, + 'client_id': oidcClientId, + }); + request.headers['content-type'] = 'application/x-www-form-urlencoded'; + final response = await httpClient.send(request); + final responseBody = await response.stream.toBytes(); + if (response.statusCode != 200) { + unexpectedResponse(response, responseBody); + } + final responseString = utf8.decode(responseBody); + final json = jsonDecode(responseString); + return OidcTokenResponse.fromJson(json); + } + + /// generates a high-entropy String with the given `length` + /// + /// The String will only contain characters considered as "unreserved" + /// according to RFC 7636. + /// + /// Reference: https://datatracker.ietf.org/doc/html/rfc7636 + String oidcGenerateUnreservedString([int length = 128]) { + final random = Random.secure(); + + // https://datatracker.ietf.org/doc/html/rfc3986#section-2.3 + const unreserved = + // [A-Z] + 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' + // [a-z] + 'abcdefghijklmnopqrstuvwxyz' + // [0-9] + '0123456789' + // "-" / "." / "_" / "~" + '-._~'; + + return String.fromCharCodes( + List.generate( + length, + (_) => unreserved.codeUnitAt(random.nextInt(unreserved.length)), + ), + ); + } +} + +class OidcCallbackResponse { + const OidcCallbackResponse( + this.state, { + this.code, + this.error, + this.errorDescription, + this.errorUri, + }); + + /// parses the raw redirect Uri received into an [OidcCallbackResponse] + factory OidcCallbackResponse.parse( + String redirectUri, [ + String responseMode = 'fragment', + ]) { + if (responseMode == 'fragment') { + redirectUri = redirectUri.replaceFirst('#', '?'); + } + final uri = Uri.parse(redirectUri); + return OidcCallbackResponse( + uri.queryParameters['state']!, + code: uri.queryParameters['code'], + error: uri.queryParameters['error'], + errorDescription: uri.queryParameters['error_description'], + errorUri: uri.queryParameters['code_uri'], + ); + } + + final String state; + final String? code; + final String? error; + final String? errorDescription; + final String? errorUri; +} + +/// represents a minimal Token Response as per +class OidcTokenResponse { + final String accessToken; + final String tokenType; + final int expiresIn; + final String refreshToken; + final Set scope; + + const OidcTokenResponse({ + required this.accessToken, + required this.tokenType, + required this.expiresIn, + required this.refreshToken, + required this.scope, + }); + + factory OidcTokenResponse.fromJson(Map json) => + OidcTokenResponse( + accessToken: json['access_token'] as String, + tokenType: json['token_type'] as String, + expiresIn: json['expires_in'] as int, + refreshToken: json['refresh_token'] as String, + scope: (json['scope'] as String).split(RegExp(r'\s')).toSet(), + ); + + Map toJson() => { + 'access_token': accessToken, + 'token_type': tokenType, + 'expires_in': expiresIn, + 'refresh_token': refreshToken, + 'scope': scope.join(' '), + }; +} diff --git a/lib/msc_extensions/msc_3861_native_oidc/msc2965_oidc_auth_metadata/msc2965_oidc_auth_metadata.dart b/lib/msc_extensions/msc_3861_native_oidc/msc2965_oidc_auth_metadata/msc2965_oidc_auth_metadata.dart new file mode 100644 index 000000000..dd22311aa --- /dev/null +++ b/lib/msc_extensions/msc_3861_native_oidc/msc2965_oidc_auth_metadata/msc2965_oidc_auth_metadata.dart @@ -0,0 +1,84 @@ +import 'dart:convert'; + +import 'package:http/http.dart' hide Client; + +import 'package:matrix/matrix.dart'; + +extension OidcProviderMetadataExtension on Client { + Future> getOidcAuthMetadata() async { + /// _matrix/client/v1/auth_metadata + final requestUri = + Uri(path: '/_matrix/client/unstable/org.matrix.msc2965/auth_metadata'); + final request = Request('GET', baseUri!.resolveUri(requestUri)); + request.headers['content-type'] = 'application/json'; + final response = await httpClient.send(request); + final responseBody = await response.stream.toBytes(); + if (response.statusCode != 200) { + unexpectedResponse(response, responseBody); + } + final responseString = utf8.decode(responseBody); + return jsonDecode(responseString); + } + + /// fallback on OIDC discovery as per MSC 2965 + /// + /// This can be used along with https://openid.net/specs/openid-connect-discovery-1_0.html . + /// + /// https://github.com/sandhose/matrix-spec-proposals/blob/msc/sandhose/oidc-discovery/proposals/2965-auth-metadata.md#discovery-via-openid-connect-discovery + @Deprecated('Use [getOidcAuthMetadata] instead.') + Future oidcAuthIssuer() async { + /// _matrix/client/v1/auth_issuer + final requestUri = + Uri(path: '/_matrix/client/unstable/org.matrix.msc2965/auth_issuer'); + final request = Request('GET', baseUri!.resolveUri(requestUri)); + request.headers['content-type'] = 'application/json'; + final response = await httpClient.send(request); + final responseBody = await response.stream.toBytes(); + if (response.statusCode != 200) { + unexpectedResponse(response, responseBody); + } + final responseString = utf8.decode(responseBody); + final json = jsonDecode(responseString); + return Uri.parse(json['issuer'] as String); + } +} + +// fallback on .well-known as per MSC 2965 +// https://github.com/sandhose/matrix-spec-proposals/blob/msc/sandhose/oidc-discovery/proposals/2965-auth-metadata.md#discovery-via-the-well-known-client-discovery +extension WellKnownAuthenticationExtension on DiscoveryInformation { + @Deprecated('Use [getOidcAuthMetadata] instead.') + DiscoveryInformationAuthenticationData? get authentication => + DiscoveryInformationAuthenticationData.fromJson( + // m.authentication + additionalProperties['org.matrix.msc2965.authentication'], + ); +} + +// Authentication discovery fallback on .well-known as per MSC 2965 +/// +/// You most probably want to use [Client.getOidcAuthMetadata] instead. +/// +/// https://github.com/sandhose/matrix-spec-proposals/blob/msc/sandhose/oidc-discovery/proposals/2965-auth-metadata.md#discovery-via-the-well-known-client-discovery +class DiscoveryInformationAuthenticationData { + const DiscoveryInformationAuthenticationData({this.issuer, this.account}); + + final Uri? issuer; + final Uri? account; + + static DiscoveryInformationAuthenticationData? fromJson(Object? json) { + if (json is! Map) { + return null; + } + final issuer = json['issuer'] as String?; + final account = json['account'] as String?; + return DiscoveryInformationAuthenticationData( + issuer: issuer == null ? null : Uri.tryParse(issuer), + account: account == null ? null : Uri.tryParse(account), + ); + } + + Map toJson() => { + if (issuer != null) 'issuer': issuer.toString(), + if (account != null) 'account': account.toString(), + }; +} diff --git a/lib/msc_extensions/msc_3861_native_oidc/msc2966_oidc_dynamic_client_registration/msc2966_oidc_dynamic_client_registration.dart b/lib/msc_extensions/msc_3861_native_oidc/msc2966_oidc_dynamic_client_registration/msc2966_oidc_dynamic_client_registration.dart new file mode 100644 index 000000000..499cc2a6b --- /dev/null +++ b/lib/msc_extensions/msc_3861_native_oidc/msc2966_oidc_dynamic_client_registration/msc2966_oidc_dynamic_client_registration.dart @@ -0,0 +1,194 @@ +import 'dart:convert'; + +import 'package:http/http.dart' hide Client; + +import 'package:matrix/matrix.dart'; + +extension OidcDynamicClientRegistrationExtension on Client { + /// checks whether an OIDC Dynamic Client ID is present for the current + /// homeserver or creates one in case not. + /// + /// returns the registered client ID or null in case the homeserver does not + /// support OIDC. + Future oidcEnsureDynamicClientId( + OidcDynamicRegistrationData registrationData, + ) async { + final storedOidcClientId = await database?.getOidcDynamicClientId(); + + if (storedOidcClientId is String) { + Logs().d('[OIDC] Reusing Dynamic Client ID $storedOidcClientId.'); + return storedOidcClientId; + } + + final metadata = oidcAuthMetadata; + if (metadata == null) return null; + final endpoint = metadata['registration_endpoint']; + if (endpoint is! String) return null; + + final oidcClientId = await oidcRegisterOAuth2Client( + registrationEndpoint: Uri.parse(endpoint), + registrationData: registrationData, + ); + await database?.storeOidcDynamicClientId(oidcClientId); + Logs().d('[OIDC] Registered Dynamic Client ID $oidcClientId.'); + return oidcClientId; + } + + /// MSC 2966 + /// + /// Performs an OIDC Dynamic Client registration at the given + /// `registrationEndpoint` with the provided `registrationData`. + /// + /// As a client developer, you will likely want to use + /// [oidcEnsureDynamicClientId] for a high-level interface instead. + Future oidcRegisterOAuth2Client({ + required Uri registrationEndpoint, + required OidcDynamicRegistrationData registrationData, + }) async { + final request = Request('POST', registrationEndpoint); + request.headers['content-type'] = 'application/json'; + request.bodyBytes = utf8.encode(jsonEncode(registrationData.toJson())); + final response = await httpClient.send(request); + final responseBody = await response.stream.toBytes(); + if (response.statusCode >= 400) { + unexpectedResponse(response, responseBody); + } + final responseString = utf8.decode(responseBody); + final json = jsonDecode(responseString); + return json['client_id'] as String; + } +} + +/// The OIDC Dynamic Client registration data +/// +/// Use [OidcDynamicRegistrationData.localized] for a high-level interface +/// providing all data required for MAS including localization. +class OidcDynamicRegistrationData { + const OidcDynamicRegistrationData({ + required this.clientName, + required this.contacts, + required this.url, + required this.logo, + required this.tos, + required this.policy, + required this.redirect, + this.responseTypes = const { + 'code', + }, + this.grantTypes = const { + 'authorization_code', + 'refresh_token', + }, + required this.applicationType, + }); + + factory OidcDynamicRegistrationData.localized({ + required Uri url, + required Set contacts, + required LocalizedOidcClientMetadata defaultLocale, + required Set redirect, + String applicationType = 'native', + Map localizations = const {}, + }) { + return OidcDynamicRegistrationData( + clientName: { + null: defaultLocale.clientName, + ...localizations.map( + (locale, localizations) => MapEntry(locale, localizations.clientName), + ), + }, + contacts: contacts, + url: url, + logo: { + null: defaultLocale.logo, + ...localizations.map( + (locale, localizations) => MapEntry(locale, localizations.logo), + ), + }, + tos: { + null: defaultLocale.tos, + ...localizations.map( + (locale, localizations) => MapEntry(locale, localizations.tos), + ), + }, + policy: { + null: defaultLocale.policy, + ...localizations.map( + (locale, localizations) => MapEntry(locale, localizations.policy), + ), + }, + redirect: redirect, + applicationType: applicationType, + ); + } + + final Map clientName; + final Uri url; + final Map logo; + final Map tos; + final Map policy; + final Set contacts; + final Set redirect; + final Set responseTypes; + final Set grantTypes; + final String applicationType; + + String _localizedKey(String key, String? localeName) => + localeName == null ? key : '$key#$localeName'; + + Map toJson() => { + ...clientName.map( + (localeName, value) => + MapEntry(_localizedKey('client_name', localeName), value), + ), + 'client_uri': url.toString(), + 'contacts': contacts.toList(), + ...logo.map( + (localeName, value) => MapEntry( + _localizedKey('logo_uri', localeName), + value.toString(), + ), + ), + ...tos.map( + (localeName, value) => MapEntry( + _localizedKey('tos_uri', localeName), + value.toString(), + ), + ), + ...policy.map( + (localeName, value) => MapEntry( + _localizedKey('policy_uri', localeName), + value.toString(), + ), + ), + // https://github.com/element-hq/matrix-authentication-service/issues/3638#issuecomment-2527352709 + 'token_endpoint_auth_method': 'none', + 'redirect_uris': redirect.map((uri) => uri.toString()).toList(), + 'response_types': responseTypes.toList(), + 'grant_types': grantTypes.toList(), + 'application_type': applicationType, + }; +} + +/// A tiny helper class around the localizable OIDC Dynamic Client Registration +/// data fields. +class LocalizedOidcClientMetadata { + const LocalizedOidcClientMetadata({ + required this.clientName, + required this.logo, + required this.tos, + required this.policy, + }); + + // client_name + final String clientName; + + // logo_uri + final Uri logo; + + // tos_uri + final Uri tos; + + // policy_uri + final Uri policy; +} diff --git a/lib/msc_extensions/msc_3861_native_oidc/msc3824_oidc_delegation/msc3824_oidc_delegation.dart b/lib/msc_extensions/msc_3861_native_oidc/msc3824_oidc_delegation/msc3824_oidc_delegation.dart new file mode 100644 index 000000000..f71d3a54f --- /dev/null +++ b/lib/msc_extensions/msc_3861_native_oidc/msc3824_oidc_delegation/msc3824_oidc_delegation.dart @@ -0,0 +1,8 @@ +import 'package:matrix/matrix.dart'; + +extension LoginFlowOidcDelegationExtention on LoginFlow { + bool get delegatedOidcCompatibility => + // delegated_oidc_compatibility + additionalProperties['org.matrix.msc3824.delegated_oidc_compatibility'] == + true; +} diff --git a/lib/msc_extensions/msc_3861_native_oidc/msc4191_account_management/msc4191_account_management.dart b/lib/msc_extensions/msc_3861_native_oidc/msc4191_account_management/msc4191_account_management.dart new file mode 100644 index 000000000..347d59a05 --- /dev/null +++ b/lib/msc_extensions/msc_3861_native_oidc/msc4191_account_management/msc4191_account_management.dart @@ -0,0 +1,44 @@ +import 'package:matrix/matrix.dart'; + +extension Msc4191AccountManagementExtension on Client { + Uri? getOidcAccountManagementUri({ + OidcAccountManagementActions? action, + String? idTokenHint, + String? deviceId, + }) { + final providerMetadata = oidcAuthMetadata; + + final rawUri = providerMetadata?['account_management_uri']; + if (rawUri is! String) { + return null; + } + + final uri = Uri.tryParse(rawUri)?.resolveUri( + Uri( + queryParameters: { + if (action is OidcAccountManagementActions) 'action': action.action, + if (deviceId is String) 'device_id': deviceId, + if (idTokenHint is String) 'id_token_hint': idTokenHint, + }, + ), + ); + return uri; + } +} + +enum OidcAccountManagementActions { + profile('profile'), + sessionsList('sessions_list'), + sessionView('session_view'), + sessionEnd('session_end'), + accountDeactivate('account_deactivate'), + crossSigningReset('cross_signing_reset'); + + const OidcAccountManagementActions(this.name); + + /// name as it appears in the metadata + final String name; + + /// action as it is used for deep linking + String get action => 'org.matrix.$name'; +} diff --git a/lib/msc_extensions/msc_3861_native_oidc/msc_3861_native_oidc.dart b/lib/msc_extensions/msc_3861_native_oidc/msc_3861_native_oidc.dart new file mode 100644 index 000000000..47c40471e --- /dev/null +++ b/lib/msc_extensions/msc_3861_native_oidc/msc_3861_native_oidc.dart @@ -0,0 +1,22 @@ +/// Support for \[matrix\] native OIDC as extensions on the Matrix Dart SDK. +/// +/// Further read: https://areweoidcyet.com/ +/// +/// This implements the following MSCs: +/// - MSC 3861 - Next-generation auth for Matrix, based on OAuth 2.0/OIDC +/// - MSC 1597 - Better spec for matrix identifiers +/// - MSC 2964 - Usage of OAuth 2.0 authorization code grant and refresh token grant +/// - MSC 2965 - OAuth 2.0 Authorization Server Metadata discovery +/// - MSC 2966 - Usage of OAuth 2.0 Dynamic Client Registration in Matrix +/// - MSC 2967 - API scopes +/// - MSC 3824 - OIDC aware clients +/// - MSC 4191 - Account management deep-linking + +library; + +export 'msc1597_matrix_identifier_syntax/msc1597_matrix_identifier_syntax.dart'; +export 'msc2964_oidc_oauth_grants/msc2964_oidc_oauth_grants.dart'; +export 'msc2965_oidc_auth_metadata/msc2965_oidc_auth_metadata.dart'; +export 'msc2966_oidc_dynamic_client_registration/msc2966_oidc_dynamic_client_registration.dart'; +export 'msc3824_oidc_delegation/msc3824_oidc_delegation.dart'; +export 'msc4191_account_management/msc4191_account_management.dart'; diff --git a/lib/src/client.dart b/lib/src/client.dart index 3be0d26e8..8985d8066 100644 --- a/lib/src/client.dart +++ b/lib/src/client.dart @@ -129,10 +129,18 @@ class Client extends MatrixApi { final Duration typingIndicatorTimeout; DiscoveryInformation? _wellKnown; + Map? _oidcAuthMetadata; + String? _oidcDynamicClientId; /// the cached .well-known file updated using [getWellknown] DiscoveryInformation? get wellKnown => _wellKnown; + /// the cached OIDC auth metadata as per MSC 2965 updated using [getWellknown] + Map? get oidcAuthMetadata => _oidcAuthMetadata; + + /// the cached OIDC auth metadata as per MSC 2966 + String? get oidcDynamicClientId => _oidcDynamicClientId; + /// The homeserver this client is communicating with. /// /// In case the [homeserver]'s host differs from the previous value, the @@ -141,7 +149,10 @@ class Client extends MatrixApi { set homeserver(Uri? homeserver) { if (this.homeserver != null && homeserver?.host != this.homeserver?.host) { _wellKnown = null; + _oidcAuthMetadata = null; unawaited(database?.storeWellKnown(null)); + unawaited(database?.storeOidcAuthMetadata(null)); + unawaited(database?.storeOidcDynamicClientId(null)); } super.homeserver = homeserver; } @@ -293,6 +304,8 @@ class Client extends MatrixApi { /// logout case. /// Throws an Exception if there is no refresh token available or the /// client is not logged in. + /// + /// This method id OIDC aware as per MSC 3824. Future refreshAccessToken() async { final storedClient = await database?.getClient(clientName); final refreshToken = storedClient?.tryGet('refresh_token'); @@ -306,24 +319,44 @@ class Client extends MatrixApi { throw Exception('Cannot refresh access token when not logged in'); } - final tokenResponse = await refreshWithCustomRefreshTokenLifetime( - refreshToken, - refreshTokenLifetimeMs: customRefreshTokenLifetime?.inMilliseconds, - ); + String accessToken; + String? newRefreshToken; + int? expiresInMs; + + final oidcTokenEndpoint = oidcAuthMetadata?['token_endpoint']; + final oidcClientId = oidcDynamicClientId; + if (oidcTokenEndpoint is String && oidcClientId != null) { + final tokenResponse = await oidcRefreshToken( + tokenEndpoint: Uri.parse(oidcTokenEndpoint), + refreshToken: refreshToken, + oidcClientId: oidcClientId, + ); + + this.accessToken = accessToken = tokenResponse.accessToken; + newRefreshToken = tokenResponse.refreshToken; + expiresInMs = tokenResponse.expiresIn; + } else { + final tokenResponse = await refreshWithCustomRefreshTokenLifetime( + refreshToken, + refreshTokenLifetimeMs: customRefreshTokenLifetime?.inMilliseconds, + ); + + this.accessToken = accessToken = tokenResponse.accessToken; + newRefreshToken = tokenResponse.refreshToken; + expiresInMs = tokenResponse.expiresInMs; + } - accessToken = tokenResponse.accessToken; - final expiresInMs = tokenResponse.expiresInMs; final tokenExpiresAt = expiresInMs == null ? null : DateTime.now().add(Duration(milliseconds: expiresInMs)); + _accessTokenExpiresAt = tokenExpiresAt; await database?.updateClient( homeserverUrl, - tokenResponse.accessToken, + accessToken, tokenExpiresAt, - tokenResponse.refreshToken, + newRefreshToken, userId, - deviceId, deviceName, prevBatch, encryption?.pickledOlmAccount, @@ -578,6 +611,9 @@ class Client extends MatrixApi { /// Note that this endpoint is not necessarily handled by the homeserver, /// but by another webserver, to be used for discovering the homeserver URL. /// + /// In case the homeserver supports OIDC, this will also request and store + /// the OIDC Auth Metadata provided by the homeserver. + /// /// The result of this call is stored in [wellKnown] for later use at runtime. @override Future getWellknown() async { @@ -587,6 +623,11 @@ class Client extends MatrixApi { super.homeserver = wellKnown.mHomeserver.baseUrl.stripTrailingSlash(); _wellKnown = wellKnown; await database?.storeWellKnown(wellKnown); + try { + final authMetadata = await getOidcAuthMetadata(); + await database?.storeOidcAuthMetadata(authMetadata); + Logs().v('[OIDC] Found auth metadata document.'); + } catch (_) {} return wellKnown; } @@ -697,7 +738,7 @@ class Client extends MatrixApi { final userId = response.userId; final homeserver_ = homeserver; if (homeserver_ == null) { - throw Exception('Registered but homerserver is null.'); + throw Exception('Registered but homeserver is null.'); } final expiresInMs = response.expiresInMs; @@ -2030,7 +2071,6 @@ class Client extends MatrixApi { // account creds if (account != null && account['homeserver_url'] != null && - account['user_id'] != null && account['token'] != null) { _id = account['client_id']; homeserver = Uri.parse(account['homeserver_url']); @@ -2041,11 +2081,12 @@ class Client extends MatrixApi { ? null : DateTime.fromMillisecondsSinceEpoch(tokenExpiresAtMs); userID = _userID = account['user_id']; - _deviceID = account['device_id']; _deviceName = account['device_name']; _syncFilterId = account['sync_filter_id']; _prevBatch = account['prev_batch']; olmAccount = account['olm_account']; + // the device ID is stored differently for easier use of MSC 1597 + _deviceID = await this.database?.getDeviceId(); } if (newToken != null) { accessToken = this.accessToken = newToken; @@ -2075,7 +2116,6 @@ class Client extends MatrixApi { accessTokenExpiresAt, newRefreshToken, userID, - _deviceID, _deviceName, prevBatch, encryption?.pickledOlmAccount, @@ -2130,7 +2170,6 @@ class Client extends MatrixApi { accessTokenExpiresAt, newRefreshToken, userID, - _deviceID, _deviceName, prevBatch, encryption?.pickledOlmAccount, @@ -2143,12 +2182,15 @@ class Client extends MatrixApi { accessTokenExpiresAt, newRefreshToken, userID, - _deviceID, _deviceName, prevBatch, encryption?.pickledOlmAccount, ); } + final deviceId = _deviceID; + if (deviceId != null) { + await database.storeDeviceId(deviceId); + } userDeviceKeysLoading = database .getUserDeviceKeys(this) .then((keys) => _userDeviceKeys = keys); @@ -2163,6 +2205,13 @@ class Client extends MatrixApi { _discoveryDataLoading = database.getWellKnown().then((data) { _wellKnown = data; }); + _oidcAuthMetadataLoading = database.getOidcAuthMetadata().then((data) { + _oidcAuthMetadata = data; + }); + _oidcDynamicClientIdLoading = + database.getOidcDynamicClientId().then((data) { + _oidcDynamicClientId = data; + }); // ignore: deprecated_member_use_from_same_package presences.clear(); if (waitUntilLoadCompletedLoaded) { @@ -3123,12 +3172,18 @@ class Client extends MatrixApi { Future? roomsLoading; Future? _accountDataLoading; Future? _discoveryDataLoading; + Future? _oidcAuthMetadataLoading; + Future? _oidcDynamicClientIdLoading; Future? firstSyncReceived; Future? get accountDataLoading => _accountDataLoading; Future? get wellKnownLoading => _discoveryDataLoading; + Future? get oidcAuthMetadataLoading => _oidcAuthMetadataLoading; + + Future? get oidcDynamicClientIdLoading => _oidcDynamicClientIdLoading; + /// A map of known device keys per user. Map get userDeviceKeys => _userDeviceKeys; Map _userDeviceKeys = {}; @@ -3883,11 +3938,11 @@ class Client extends MatrixApi { : DateTime.fromMillisecondsSinceEpoch(tokenExpiresAtMs), migrateClient['refresh_token'], migrateClient['user_id'], - migrateClient['device_id'], migrateClient['device_name'], null, migrateClient['olm_account'], ); + await database.storeDeviceId(migrateClient['device_id']); Logs().d('Migrate SSSSCache...'); for (final type in cacheTypes) { final ssssCache = await legacyDatabase.getSSSSCache(type); diff --git a/lib/src/database/database_api.dart b/lib/src/database/database_api.dart index 644ecfff5..324ce58c3 100644 --- a/lib/src/database/database_api.dart +++ b/lib/src/database/database_api.dart @@ -38,7 +38,6 @@ abstract class DatabaseApi { DateTime? tokenExpiresAt, String? refreshToken, String userId, - String? deviceId, String? deviceName, String? prevBatch, String? olmAccount, @@ -51,7 +50,6 @@ abstract class DatabaseApi { DateTime? tokenExpiresAt, String? refreshToken, String userId, - String? deviceId, String? deviceName, String? prevBatch, String? olmAccount, @@ -357,6 +355,18 @@ abstract class DatabaseApi { Future getWellKnown(); + Future storeDeviceId(String deviceId); + + Future getDeviceId(); + + Future storeOidcAuthMetadata(Map? authMetadata); + + Future?> getOidcAuthMetadata(); + + Future storeOidcDynamicClientId(String? oidcClientId); + + Future getOidcDynamicClientId(); + /// Deletes the whole database. The database needs to be created again after /// this. Future delete(); diff --git a/lib/src/database/hive_collections_database.dart b/lib/src/database/hive_collections_database.dart index cdc57c4e3..a476d717e 100644 --- a/lib/src/database/hive_collections_database.dart +++ b/lib/src/database/hive_collections_database.dart @@ -847,7 +847,6 @@ class HiveCollectionsDatabase extends DatabaseApi { DateTime? tokenExpiresAt, String? refreshToken, String userId, - String? deviceId, String? deviceName, String? prevBatch, String? olmAccount, @@ -869,11 +868,7 @@ class HiveCollectionsDatabase extends DatabaseApi { tokenExpiresAt.millisecondsSinceEpoch.toString(), ); } - if (deviceId == null) { - await _clientBox.delete('device_id'); - } else { - await _clientBox.put('device_id', deviceId); - } + await _clientBox.delete('device_id'); if (deviceName == null) { await _clientBox.delete('device_name'); } else { @@ -1480,7 +1475,6 @@ class HiveCollectionsDatabase extends DatabaseApi { DateTime? tokenExpiresAt, String? refreshToken, String userId, - String? deviceId, String? deviceName, String? prevBatch, String? olmAccount, @@ -1502,11 +1496,7 @@ class HiveCollectionsDatabase extends DatabaseApi { await _clientBox.put('refresh_token', refreshToken); } await _clientBox.put('user_id', userId); - if (deviceId == null) { - await _clientBox.delete('device_id'); - } else { - await _clientBox.put('device_id', deviceId); - } + await _clientBox.delete('device_id'); if (deviceName == null) { await _clientBox.delete('device_name'); } else { @@ -1754,6 +1744,50 @@ class HiveCollectionsDatabase extends DatabaseApi { return DiscoveryInformation.fromJson(jsonDecode(rawDiscoveryInformation)); } + @override + Future storeOidcAuthMetadata(Map? authMetadata) { + if (authMetadata == null) { + return _clientBox.delete('oidc_auth_metadata'); + } + return _clientBox.put( + 'oidc_auth_metadata', + jsonEncode(authMetadata), + ); + } + + @override + Future?> getOidcAuthMetadata() async { + final rawAuthMetadata = await _clientBox.get('oidc_auth_metadata'); + if (rawAuthMetadata == null) return null; + return jsonDecode(rawAuthMetadata); + } + + @override + Future storeDeviceId(String? deviceId) { + if (deviceId == null) { + return _clientBox.delete('device_id'); + } + return _clientBox.put('device_id', deviceId); + } + + @override + Future getDeviceId() { + return _clientBox.get('device_id'); + } + + @override + Future storeOidcDynamicClientId(String? oidcClientId) { + if (oidcClientId == null) { + return _clientBox.delete('oidc_dynamic_client_id'); + } + return _clientBox.put('oidc_dynamic_client_id', oidcClientId); + } + + @override + Future getOidcDynamicClientId() { + return _clientBox.get('oidc_dynamic_client_id'); + } + @override Future delete() => _collection.deleteFromDisk(); diff --git a/lib/src/database/matrix_sdk_database.dart b/lib/src/database/matrix_sdk_database.dart index 43965987e..8bec9358e 100644 --- a/lib/src/database/matrix_sdk_database.dart +++ b/lib/src/database/matrix_sdk_database.dart @@ -31,11 +31,10 @@ import 'package:matrix/src/utils/copy_map.dart'; import 'package:matrix/src/utils/queued_to_device_event.dart'; import 'package:matrix/src/utils/run_benchmarked.dart'; -import 'package:matrix/src/database/indexeddb_box.dart' - if (dart.library.io) 'package:matrix/src/database/sqflite_box.dart'; - import 'package:matrix/src/database/database_file_storage_stub.dart' if (dart.library.io) 'package:matrix/src/database/database_file_storage_io.dart'; +import 'package:matrix/src/database/indexeddb_box.dart' + if (dart.library.io) 'package:matrix/src/database/sqflite_box.dart'; /// Database based on SQlite3 on native and IndexedDB on web. For native you /// have to pass a `Database` object, which can be created with the sqflite @@ -829,7 +828,6 @@ class MatrixSdkDatabase extends DatabaseApi with DatabaseFileStorage { DateTime? tokenExpiresAt, String? refreshToken, String userId, - String? deviceId, String? deviceName, String? prevBatch, String? olmAccount, @@ -851,11 +849,7 @@ class MatrixSdkDatabase extends DatabaseApi with DatabaseFileStorage { await _clientBox.put('refresh_token', refreshToken); } await _clientBox.put('user_id', userId); - if (deviceId == null) { - await _clientBox.delete('device_id'); - } else { - await _clientBox.put('device_id', deviceId); - } + await _clientBox.delete('device_id'); if (deviceName == null) { await _clientBox.delete('device_name'); } else { @@ -1444,7 +1438,6 @@ class MatrixSdkDatabase extends DatabaseApi with DatabaseFileStorage { DateTime? tokenExpiresAt, String? refreshToken, String userId, - String? deviceId, String? deviceName, String? prevBatch, String? olmAccount, @@ -1466,11 +1459,7 @@ class MatrixSdkDatabase extends DatabaseApi with DatabaseFileStorage { await _clientBox.put('refresh_token', refreshToken); } await _clientBox.put('user_id', userId); - if (deviceId == null) { - await _clientBox.delete('device_id'); - } else { - await _clientBox.put('device_id', deviceId); - } + await _clientBox.delete('device_id'); if (deviceName == null) { await _clientBox.delete('device_name'); } else { @@ -1775,6 +1764,50 @@ class MatrixSdkDatabase extends DatabaseApi with DatabaseFileStorage { return DiscoveryInformation.fromJson(jsonDecode(rawDiscoveryInformation)); } + @override + Future storeOidcAuthMetadata(Map? authMetadata) { + if (authMetadata == null) { + return _clientBox.delete('auth_metadata'); + } + return _clientBox.put( + 'auth_metadata', + jsonEncode(authMetadata), + ); + } + + @override + Future?> getOidcAuthMetadata() async { + final rawAuthMetadata = await _clientBox.get('auth_metadata'); + if (rawAuthMetadata == null) return null; + return jsonDecode(rawAuthMetadata); + } + + @override + Future storeDeviceId(String? deviceId) { + if (deviceId == null) { + return _clientBox.delete('device_id'); + } + return _clientBox.put('device_id', deviceId); + } + + @override + Future getDeviceId() { + return _clientBox.get('device_id'); + } + + @override + Future storeOidcDynamicClientId(String? oidcClientId) { + if (oidcClientId == null) { + return _clientBox.delete('oidc_dynamic_client_id'); + } + return _clientBox.put('oidc_dynamic_client_id', oidcClientId); + } + + @override + Future getOidcDynamicClientId() { + return _clientBox.get('oidc_dynamic_client_id'); + } + @override Future delete() async { // database?.path is null on web diff --git a/test/database_api_test.dart b/test/database_api_test.dart index 85d1e3ecd..ec1ac1d43 100644 --- a/test/database_api_test.dart +++ b/test/database_api_test.dart @@ -145,7 +145,6 @@ void main() { now, 'refresh_token', 'userId', - 'deviceId', 'deviceName', 'prevBatch', 'olmAccount', @@ -165,7 +164,6 @@ void main() { DateTime.now(), 'refresh_token', 'userId', - 'deviceId', 'deviceName', 'prevBatch', 'olmAccount', diff --git a/test/matrix_database_test.dart b/test/matrix_database_test.dart index 4fb9834e8..5313e4d6b 100644 --- a/test/matrix_database_test.dart +++ b/test/matrix_database_test.dart @@ -38,7 +38,6 @@ void main() { null, null, null, - null, ); }); From 5034107ec285b65ed887cb2324c254db6cde7840 Mon Sep 17 00:00:00 2001 From: The one with the braid Date: Fri, 7 Feb 2025 22:49:21 +0100 Subject: [PATCH 02/17] fix: wrong response_type parameter Signed-off-by: The one with the braid --- .../msc2964_oidc_oauth_grants/msc2964_oidc_oauth_grants.dart | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/lib/msc_extensions/msc_3861_native_oidc/msc2964_oidc_oauth_grants/msc2964_oidc_oauth_grants.dart b/lib/msc_extensions/msc_3861_native_oidc/msc2964_oidc_oauth_grants/msc2964_oidc_oauth_grants.dart index 5ff7f1aa5..4083b50cd 100644 --- a/lib/msc_extensions/msc_3861_native_oidc/msc2964_oidc_oauth_grants/msc2964_oidc_oauth_grants.dart +++ b/lib/msc_extensions/msc_3861_native_oidc/msc2964_oidc_oauth_grants/msc2964_oidc_oauth_grants.dart @@ -11,7 +11,6 @@ extension OidcOauthGrantFlowExtension on Client { Future oidcAuthorizationGrantFlow({ required Completer nativeCompleter, required String oidcClientId, - required String responseType, required Uri redirectUri, required String responseMode, String? initialDeviceDisplayName, @@ -52,7 +51,6 @@ extension OidcOauthGrantFlowExtension on Client { await oidcStartOAuth2( authorizationEndpoint: authEndpoint, oidcClientId: oidcClientId, - responseType: responseType, redirectUri: redirectUri, scope: [ 'openid', @@ -121,7 +119,6 @@ extension OidcOauthGrantFlowExtension on Client { Future oidcStartOAuth2({ required Uri authorizationEndpoint, required String oidcClientId, - required String responseType, required Uri redirectUri, required List scope, required String responseMode, @@ -135,7 +132,7 @@ extension OidcOauthGrantFlowExtension on Client { final requestUri = authorizationEndpoint.replace( queryParameters: { 'client_id': oidcClientId, - 'response_type': responseType, + 'response_type': 'code', 'response_mode': responseMode, 'redirect_uri': redirectUri.toString(), 'scope': scope.join(' '), From 817186c6650cfec65c2aabf3abd459b03e225f2a Mon Sep 17 00:00:00 2001 From: The one with the braid Date: Sat, 8 Feb 2025 00:02:49 +0100 Subject: [PATCH 03/17] feat: implement a callback to hand over the OAuth2.0 authorization uri to client Signed-off-by: The one with the braid --- .../msc2964_oidc_oauth_grants.dart | 25 +++++++-------- ...2966_oidc_dynamic_client_registration.dart | 5 +-- lib/src/client.dart | 31 +++++++++++-------- 3 files changed, 33 insertions(+), 28 deletions(-) diff --git a/lib/msc_extensions/msc_3861_native_oidc/msc2964_oidc_oauth_grants/msc2964_oidc_oauth_grants.dart b/lib/msc_extensions/msc_3861_native_oidc/msc2964_oidc_oauth_grants/msc2964_oidc_oauth_grants.dart index 4083b50cd..4d82487ef 100644 --- a/lib/msc_extensions/msc_3861_native_oidc/msc2964_oidc_oauth_grants/msc2964_oidc_oauth_grants.dart +++ b/lib/msc_extensions/msc_3861_native_oidc/msc2964_oidc_oauth_grants/msc2964_oidc_oauth_grants.dart @@ -13,6 +13,7 @@ extension OidcOauthGrantFlowExtension on Client { required String oidcClientId, required Uri redirectUri, required String responseMode, + required void Function(Uri oauth2uri) launchOAuth2Uri, String? initialDeviceDisplayName, bool enforceNewDeviceId = false, String? prompt, @@ -47,8 +48,8 @@ extension OidcOauthGrantFlowExtension on Client { rethrow; } - // launch the OAuth2 request at the IDP - await oidcStartOAuth2( + // generate the OAuth2 uri to authenticate at the IDP + final uri = await oidcMakeOAuth2Uri( authorizationEndpoint: authEndpoint, oidcClientId: oidcClientId, redirectUri: redirectUri, @@ -64,6 +65,8 @@ extension OidcOauthGrantFlowExtension on Client { codeVerifier: verifier, prompt: prompt, ); + // hand the OAuth2 uri over to the matrix client + launchOAuth2Uri.call(uri); // wait for the matrix client to receive the redirect callback from the IDP final nativeResponse = await nativeCompleter.future; @@ -72,7 +75,8 @@ extension OidcOauthGrantFlowExtension on Client { final oAuth2Code = nativeResponse.code; if (nativeResponse.error != null || oAuth2Code == null) { Logs().e( - '[OIDC] OAuth2 error ${nativeResponse.error}: ${nativeResponse.errorDescription} - ${nativeResponse.errorUri}'); + '[OIDC] OAuth2 error ${nativeResponse.error}: ${nativeResponse.errorDescription} - ${nativeResponse.errorUri}', + ); throw nativeResponse; } @@ -110,13 +114,14 @@ extension OidcOauthGrantFlowExtension on Client { ); } - /// Starts an OAuth2 flow + /// Computes an OAuth2 flow authorization Uri /// /// - generates the challenge for the `codeVerifier` as per RFC 7636 - /// - dispatches the request + /// - builds the query to launch for authorization + /// - returns the full uri /// /// Parameters: https://github.com/sandhose/matrix-spec-proposals/blob/msc/sandhose/oauth2-profile/proposals/2964-oauth2-profile.md#flow-parameters - Future oidcStartOAuth2({ + Future oidcMakeOAuth2Uri({ required Uri authorizationEndpoint, required String oidcClientId, required Uri redirectUri, @@ -141,13 +146,7 @@ extension OidcOauthGrantFlowExtension on Client { 'code_challenge_method': 'S256', }, ); - final request = Request('GET', requestUri); - request.headers['content-type'] = 'application/json'; - final response = await httpClient.send(request); - final responseBody = await response.stream.toBytes(); - if (response.statusCode != 200) { - unexpectedResponse(response, responseBody); - } + return requestUri; } /// Exchanges an OIDC OAuth2 code into an access token diff --git a/lib/msc_extensions/msc_3861_native_oidc/msc2966_oidc_dynamic_client_registration/msc2966_oidc_dynamic_client_registration.dart b/lib/msc_extensions/msc_3861_native_oidc/msc2966_oidc_dynamic_client_registration/msc2966_oidc_dynamic_client_registration.dart index 499cc2a6b..6a5e3412f 100644 --- a/lib/msc_extensions/msc_3861_native_oidc/msc2966_oidc_dynamic_client_registration/msc2966_oidc_dynamic_client_registration.dart +++ b/lib/msc_extensions/msc_3861_native_oidc/msc2966_oidc_dynamic_client_registration/msc2966_oidc_dynamic_client_registration.dart @@ -13,7 +13,8 @@ extension OidcDynamicClientRegistrationExtension on Client { Future oidcEnsureDynamicClientId( OidcDynamicRegistrationData registrationData, ) async { - final storedOidcClientId = await database?.getOidcDynamicClientId(); + final storedOidcClientId = + oidcDynamicClientId = await database?.getOidcDynamicClientId(); if (storedOidcClientId is String) { Logs().d('[OIDC] Reusing Dynamic Client ID $storedOidcClientId.'); @@ -31,7 +32,7 @@ extension OidcDynamicClientRegistrationExtension on Client { ); await database?.storeOidcDynamicClientId(oidcClientId); Logs().d('[OIDC] Registered Dynamic Client ID $oidcClientId.'); - return oidcClientId; + return oidcDynamicClientId = oidcClientId; } /// MSC 2966 diff --git a/lib/src/client.dart b/lib/src/client.dart index 8985d8066..911e2bb79 100644 --- a/lib/src/client.dart +++ b/lib/src/client.dart @@ -130,7 +130,6 @@ class Client extends MatrixApi { DiscoveryInformation? _wellKnown; Map? _oidcAuthMetadata; - String? _oidcDynamicClientId; /// the cached .well-known file updated using [getWellknown] DiscoveryInformation? get wellKnown => _wellKnown; @@ -139,7 +138,7 @@ class Client extends MatrixApi { Map? get oidcAuthMetadata => _oidcAuthMetadata; /// the cached OIDC auth metadata as per MSC 2966 - String? get oidcDynamicClientId => _oidcDynamicClientId; + String? oidcDynamicClientId; /// The homeserver this client is communicating with. /// @@ -617,17 +616,23 @@ class Client extends MatrixApi { /// The result of this call is stored in [wellKnown] for later use at runtime. @override Future getWellknown() async { - final wellKnown = await super.getWellknown(); - - // do not reset the well known here, so super call - super.homeserver = wellKnown.mHomeserver.baseUrl.stripTrailingSlash(); - _wellKnown = wellKnown; - await database?.storeWellKnown(wellKnown); + DiscoveryInformation wellKnown; try { - final authMetadata = await getOidcAuthMetadata(); - await database?.storeOidcAuthMetadata(authMetadata); - Logs().v('[OIDC] Found auth metadata document.'); - } catch (_) {} + wellKnown = await super.getWellknown(); + + // do not reset the well known here, so super call + super.homeserver = wellKnown.mHomeserver.baseUrl.stripTrailingSlash(); + _wellKnown = wellKnown; + await database?.storeWellKnown(wellKnown); + } finally { + try { + _oidcAuthMetadata = await getOidcAuthMetadata(); + await database?.storeOidcAuthMetadata(_oidcAuthMetadata); + Logs().v('[OIDC] Found auth metadata document.'); + } catch (e) { + Logs().v('[OIDC] Homeserver does not support OIDC delegation.', e); + } + } return wellKnown; } @@ -2210,7 +2215,7 @@ class Client extends MatrixApi { }); _oidcDynamicClientIdLoading = database.getOidcDynamicClientId().then((data) { - _oidcDynamicClientId = data; + oidcDynamicClientId = data; }); // ignore: deprecated_member_use_from_same_package presences.clear(); From 989b7bfe3f3f5fa36f277623c96b81908d0356f9 Mon Sep 17 00:00:00 2001 From: The one with the braid Date: Sat, 8 Feb 2025 08:07:49 +0100 Subject: [PATCH 04/17] feat: add parameter to enforce new dynamic client registration Signed-off-by: The one with the braid --- ...2966_oidc_dynamic_client_registration.dart | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/lib/msc_extensions/msc_3861_native_oidc/msc2966_oidc_dynamic_client_registration/msc2966_oidc_dynamic_client_registration.dart b/lib/msc_extensions/msc_3861_native_oidc/msc2966_oidc_dynamic_client_registration/msc2966_oidc_dynamic_client_registration.dart index 6a5e3412f..3a6a6340a 100644 --- a/lib/msc_extensions/msc_3861_native_oidc/msc2966_oidc_dynamic_client_registration/msc2966_oidc_dynamic_client_registration.dart +++ b/lib/msc_extensions/msc_3861_native_oidc/msc2966_oidc_dynamic_client_registration/msc2966_oidc_dynamic_client_registration.dart @@ -11,14 +11,17 @@ extension OidcDynamicClientRegistrationExtension on Client { /// returns the registered client ID or null in case the homeserver does not /// support OIDC. Future oidcEnsureDynamicClientId( - OidcDynamicRegistrationData registrationData, - ) async { - final storedOidcClientId = - oidcDynamicClientId = await database?.getOidcDynamicClientId(); - - if (storedOidcClientId is String) { - Logs().d('[OIDC] Reusing Dynamic Client ID $storedOidcClientId.'); - return storedOidcClientId; + OidcDynamicRegistrationData registrationData, { + bool enforceNewDynamicClient = false, + }) async { + if (!enforceNewDynamicClient) { + final storedOidcClientId = + oidcDynamicClientId = await database?.getOidcDynamicClientId(); + + if (storedOidcClientId is String) { + Logs().d('[OIDC] Reusing Dynamic Client ID $storedOidcClientId.'); + return storedOidcClientId; + } } final metadata = oidcAuthMetadata; From f92cb8c22241db7c1be7ebc1825839bc97c156ae Mon Sep 17 00:00:00 2001 From: The one with the braid Date: Sat, 8 Feb 2025 08:09:12 +0100 Subject: [PATCH 05/17] fix: still fetch auth metadata when well known is not served Signed-off-by: The one with the braid --- lib/src/client.dart | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/src/client.dart b/lib/src/client.dart index 911e2bb79..dab222a55 100644 --- a/lib/src/client.dart +++ b/lib/src/client.dart @@ -616,14 +616,15 @@ class Client extends MatrixApi { /// The result of this call is stored in [wellKnown] for later use at runtime. @override Future getWellknown() async { - DiscoveryInformation wellKnown; try { - wellKnown = await super.getWellknown(); + final wellKnown = await super.getWellknown(); // do not reset the well known here, so super call super.homeserver = wellKnown.mHomeserver.baseUrl.stripTrailingSlash(); _wellKnown = wellKnown; await database?.storeWellKnown(wellKnown); + + return wellKnown; } finally { try { _oidcAuthMetadata = await getOidcAuthMetadata(); @@ -633,7 +634,6 @@ class Client extends MatrixApi { Logs().v('[OIDC] Homeserver does not support OIDC delegation.', e); } } - return wellKnown; } /// Checks to see if a username is available, and valid, for the server. From bc31297e297c798e50ec5e38252e775e28b8121b Mon Sep 17 00:00:00 2001 From: The one with the braid Date: Sat, 8 Feb 2025 08:09:33 +0100 Subject: [PATCH 06/17] feat: improve documentation Signed-off-by: The one with the braid --- .../msc2964_oidc_oauth_grants/msc2964_oidc_oauth_grants.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/msc_extensions/msc_3861_native_oidc/msc2964_oidc_oauth_grants/msc2964_oidc_oauth_grants.dart b/lib/msc_extensions/msc_3861_native_oidc/msc2964_oidc_oauth_grants/msc2964_oidc_oauth_grants.dart index 4d82487ef..41bdb2351 100644 --- a/lib/msc_extensions/msc_3861_native_oidc/msc2964_oidc_oauth_grants/msc2964_oidc_oauth_grants.dart +++ b/lib/msc_extensions/msc_3861_native_oidc/msc2964_oidc_oauth_grants/msc2964_oidc_oauth_grants.dart @@ -40,8 +40,8 @@ extension OidcOauthGrantFlowExtension on Client { prompt = null; } } - // we do not check the *_supported flags since we assume the homeserver - // is properly set up + // we do not check any other *_supported flags since we assume the + // homeserver is properly set up // https://github.com/sandhose/matrix-spec-proposals/blob/msc/sandhose/oauth2-profile/proposals/2964-oauth2-profile.md#prerequisites } catch (e, s) { Logs().e('[OIDC] Auth Metadata not valid according to MSC2965.', e, s); From b6df008b88d3b0232da8ca7f415345853d4441a5 Mon Sep 17 00:00:00 2001 From: The one with the braid Date: Sat, 8 Feb 2025 08:33:09 +0100 Subject: [PATCH 07/17] fix: include scope into oauth2 redirect Signed-off-by: The one with the braid --- .../msc2964_oidc_oauth_grants/msc2964_oidc_oauth_grants.dart | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/msc_extensions/msc_3861_native_oidc/msc2964_oidc_oauth_grants/msc2964_oidc_oauth_grants.dart b/lib/msc_extensions/msc_3861_native_oidc/msc2964_oidc_oauth_grants/msc2964_oidc_oauth_grants.dart index 41bdb2351..5bf76e958 100644 --- a/lib/msc_extensions/msc_3861_native_oidc/msc2964_oidc_oauth_grants/msc2964_oidc_oauth_grants.dart +++ b/lib/msc_extensions/msc_3861_native_oidc/msc2964_oidc_oauth_grants/msc2964_oidc_oauth_grants.dart @@ -141,6 +141,9 @@ extension OidcOauthGrantFlowExtension on Client { 'response_mode': responseMode, 'redirect_uri': redirectUri.toString(), 'scope': scope.join(' '), + // not required per RFC but included due to + // https://github.com/element-hq/matrix-authentication-service/issues/2869 + 'state': state, if (prompt != null) 'prompt': prompt, 'code_challenge': base64Encode(codeChallenge), 'code_challenge_method': 'S256', From df16d0ca3aa57cddc5dd3b7c9582ce4c4456b9e9 Mon Sep 17 00:00:00 2001 From: The one with the braid Date: Sat, 8 Feb 2025 14:19:15 +0100 Subject: [PATCH 08/17] fix: revert unwanted change in Client Signed-off-by: The one with the braid --- lib/src/client.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/src/client.dart b/lib/src/client.dart index dab222a55..8940f6e5d 100644 --- a/lib/src/client.dart +++ b/lib/src/client.dart @@ -2076,6 +2076,7 @@ class Client extends MatrixApi { // account creds if (account != null && account['homeserver_url'] != null && + account['user_id'] != null && account['token'] != null) { _id = account['client_id']; homeserver = Uri.parse(account['homeserver_url']); From 2555f74c8d7377b1cabdbaf3da83ef58b58e4a15 Mon Sep 17 00:00:00 2001 From: The one with the braid Date: Sat, 8 Feb 2025 14:20:34 +0100 Subject: [PATCH 09/17] fix: properly calculate PKCE for OAuth2 token exchange Signed-off-by: The one with the braid --- .../msc2964_oidc_oauth_grants.dart | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/lib/msc_extensions/msc_3861_native_oidc/msc2964_oidc_oauth_grants/msc2964_oidc_oauth_grants.dart b/lib/msc_extensions/msc_3861_native_oidc/msc2964_oidc_oauth_grants/msc2964_oidc_oauth_grants.dart index 5bf76e958..d1e6d4aa5 100644 --- a/lib/msc_extensions/msc_3861_native_oidc/msc2964_oidc_oauth_grants/msc2964_oidc_oauth_grants.dart +++ b/lib/msc_extensions/msc_3861_native_oidc/msc2964_oidc_oauth_grants/msc2964_oidc_oauth_grants.dart @@ -132,7 +132,8 @@ extension OidcOauthGrantFlowExtension on Client { String? prompt, }) async { // https://datatracker.ietf.org/doc/html/rfc7636#section-4.2 - final codeChallenge = await sha256.call(latin1.encode(codeVerifier)); + final codeChallenge = await sha256.call(ascii.encode(codeVerifier)); + final encodedChallenge = base64UrlEncode(codeChallenge); final requestUri = authorizationEndpoint.replace( queryParameters: { @@ -145,7 +146,9 @@ extension OidcOauthGrantFlowExtension on Client { // https://github.com/element-hq/matrix-authentication-service/issues/2869 'state': state, if (prompt != null) 'prompt': prompt, - 'code_challenge': base64Encode(codeChallenge), + 'code_challenge': + // remove the "=" padding + encodedChallenge.substring(0, encodedChallenge.length - 1), 'code_challenge_method': 'S256', }, ); From 15707b919391925a45736d742cc0f21dd4e5d941 Mon Sep 17 00:00:00 2001 From: The one with the braid Date: Sat, 8 Feb 2025 14:21:32 +0100 Subject: [PATCH 10/17] fix: correctly assign request form body for OAuth2 token requests Signed-off-by: The one with the braid --- .../msc2964_oidc_oauth_grants.dart | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/lib/msc_extensions/msc_3861_native_oidc/msc2964_oidc_oauth_grants/msc2964_oidc_oauth_grants.dart b/lib/msc_extensions/msc_3861_native_oidc/msc2964_oidc_oauth_grants/msc2964_oidc_oauth_grants.dart index d1e6d4aa5..c199d92dd 100644 --- a/lib/msc_extensions/msc_3861_native_oidc/msc2964_oidc_oauth_grants/msc2964_oidc_oauth_grants.dart +++ b/lib/msc_extensions/msc_3861_native_oidc/msc2964_oidc_oauth_grants/msc2964_oidc_oauth_grants.dart @@ -166,14 +166,13 @@ extension OidcOauthGrantFlowExtension on Client { required String codeVerifier, }) async { final request = Request('POST', tokenEndpoint); - request.bodyFields.addAll({ + request.bodyFields = { 'grant_type': 'authorization_code', 'code': oAuth2Code, 'redirect_uri': redirectUri.toString(), 'client_id': oidcClientId, 'code_verifier': codeVerifier, - }); - request.headers['content-type'] = 'application/x-www-form-urlencoded'; + }; final response = await httpClient.send(request); final responseBody = await response.stream.toBytes(); if (response.statusCode != 200) { @@ -193,12 +192,11 @@ extension OidcOauthGrantFlowExtension on Client { required String oidcClientId, }) async { final request = Request('POST', tokenEndpoint); - request.bodyFields.addAll({ + request.bodyFields = { 'grant_type': 'refresh_token', 'refresh_token': refreshToken, 'client_id': oidcClientId, - }); - request.headers['content-type'] = 'application/x-www-form-urlencoded'; + }; final response = await httpClient.send(request); final responseBody = await response.stream.toBytes(); if (response.statusCode != 200) { From c00e51e5e98a7b814243c8d8c9daadc0961ddb9e Mon Sep 17 00:00:00 2001 From: The one with the braid Date: Sat, 8 Feb 2025 15:44:19 +0100 Subject: [PATCH 11/17] fix: migration of device ID to individual database entry Signed-off-by: The one with the braid --- lib/src/client.dart | 13 +++++++++++-- lib/src/database/hive_collections_database.dart | 1 - lib/src/database/matrix_sdk_database.dart | 1 - 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/lib/src/client.dart b/lib/src/client.dart index 8940f6e5d..d6a1f74b6 100644 --- a/lib/src/client.dart +++ b/lib/src/client.dart @@ -2071,6 +2071,17 @@ class Client extends MatrixApi { _versionsCache.invalidate(); final account = await this.database?.getClient(clientName); + + // the device ID is stored separately for easier use of MSC 1597 + _deviceID = await this.database?.getDeviceId(); + // migrate the device ID if still in account data + if (_deviceID == null && + account != null && + account.containsKey('device_id')) { + final deviceId = _deviceID = account['device_id']; + await this.database?.storeDeviceId(deviceId); + } + newRefreshToken ??= account?.tryGet('refresh_token'); // can have discovery_information so make sure it also has the proper // account creds @@ -2091,8 +2102,6 @@ class Client extends MatrixApi { _syncFilterId = account['sync_filter_id']; _prevBatch = account['prev_batch']; olmAccount = account['olm_account']; - // the device ID is stored differently for easier use of MSC 1597 - _deviceID = await this.database?.getDeviceId(); } if (newToken != null) { accessToken = this.accessToken = newToken; diff --git a/lib/src/database/hive_collections_database.dart b/lib/src/database/hive_collections_database.dart index a476d717e..f025d3358 100644 --- a/lib/src/database/hive_collections_database.dart +++ b/lib/src/database/hive_collections_database.dart @@ -868,7 +868,6 @@ class HiveCollectionsDatabase extends DatabaseApi { tokenExpiresAt.millisecondsSinceEpoch.toString(), ); } - await _clientBox.delete('device_id'); if (deviceName == null) { await _clientBox.delete('device_name'); } else { diff --git a/lib/src/database/matrix_sdk_database.dart b/lib/src/database/matrix_sdk_database.dart index 8bec9358e..2bc6a57c3 100644 --- a/lib/src/database/matrix_sdk_database.dart +++ b/lib/src/database/matrix_sdk_database.dart @@ -1459,7 +1459,6 @@ class MatrixSdkDatabase extends DatabaseApi with DatabaseFileStorage { await _clientBox.put('refresh_token', refreshToken); } await _clientBox.put('user_id', userId); - await _clientBox.delete('device_id'); if (deviceName == null) { await _clientBox.delete('device_name'); } else { From 24a81ebcc70d379e01a9bc3cc48fbf7597a53a3c Mon Sep 17 00:00:00 2001 From: The one with the braid Date: Sun, 9 Feb 2025 11:19:35 +0100 Subject: [PATCH 12/17] chore: better parse redirect uri query Signed-off-by: The one with the braid --- .../msc2964_oidc_oauth_grants.dart | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/lib/msc_extensions/msc_3861_native_oidc/msc2964_oidc_oauth_grants/msc2964_oidc_oauth_grants.dart b/lib/msc_extensions/msc_3861_native_oidc/msc2964_oidc_oauth_grants/msc2964_oidc_oauth_grants.dart index c199d92dd..05e2bd9ec 100644 --- a/lib/msc_extensions/msc_3861_native_oidc/msc2964_oidc_oauth_grants/msc2964_oidc_oauth_grants.dart +++ b/lib/msc_extensions/msc_3861_native_oidc/msc2964_oidc_oauth_grants/msc2964_oidc_oauth_grants.dart @@ -250,16 +250,21 @@ class OidcCallbackResponse { String redirectUri, [ String responseMode = 'fragment', ]) { + Uri search; + // parse either fragment or query into Uri for easier search handling if (responseMode == 'fragment') { - redirectUri = redirectUri.replaceFirst('#', '?'); + search = Uri(query: Uri.parse(redirectUri).fragment); + } else if (responseMode == 'query') { + search = Uri(query: Uri.parse(redirectUri).query); + } else { + search = Uri.parse(redirectUri); } - final uri = Uri.parse(redirectUri); return OidcCallbackResponse( - uri.queryParameters['state']!, - code: uri.queryParameters['code'], - error: uri.queryParameters['error'], - errorDescription: uri.queryParameters['error_description'], - errorUri: uri.queryParameters['code_uri'], + search.queryParameters['state']!, + code: search.queryParameters['code'], + error: search.queryParameters['error'], + errorDescription: search.queryParameters['error_description'], + errorUri: search.queryParameters['code_uri'], ); } From 3e45d9a0844100024840a84984f1fc49cd3d6c8a Mon Sep 17 00:00:00 2001 From: The one with the braid Date: Sun, 9 Feb 2025 12:17:09 +0100 Subject: [PATCH 13/17] feat: support all possible methods for OIDC discovery Signed-off-by: The one with the braid --- .../msc2965_oidc_auth_metadata.dart | 29 +++++++++++++++++++ lib/src/client.dart | 21 +++++++++++++- 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/lib/msc_extensions/msc_3861_native_oidc/msc2965_oidc_auth_metadata/msc2965_oidc_auth_metadata.dart b/lib/msc_extensions/msc_3861_native_oidc/msc2965_oidc_auth_metadata/msc2965_oidc_auth_metadata.dart index dd22311aa..4cb6d296b 100644 --- a/lib/msc_extensions/msc_3861_native_oidc/msc2965_oidc_auth_metadata/msc2965_oidc_auth_metadata.dart +++ b/lib/msc_extensions/msc_3861_native_oidc/msc2965_oidc_auth_metadata/msc2965_oidc_auth_metadata.dart @@ -5,6 +5,16 @@ import 'package:http/http.dart' hide Client; import 'package:matrix/matrix.dart'; extension OidcProviderMetadataExtension on Client { + /// Loads the Auth Metadata from the homeserver + /// + /// Even though homeservers might still use the previous proposed approaches + /// for delegating OIDC discovery, this is the preferred way to fetch the + /// OIDC Auth Metadata. + /// + /// Since the OIDC spec is very flexible with what to expect in this document, + /// the result is simply returned as a [Map]. + /// + /// Reference: https://github.com/sandhose/matrix-spec-proposals/blob/msc/sandhose/oidc-discovery/proposals/2965-auth-metadata.md#get-auth_metadata Future> getOidcAuthMetadata() async { /// _matrix/client/v1/auth_metadata final requestUri = @@ -20,6 +30,25 @@ extension OidcProviderMetadataExtension on Client { return jsonDecode(responseString); } + /// fallback on OIDC discovery via .well-known as per MSC 2965 + /// + /// Reference: https://openid.net/specs/openid-connect-discovery-1_0.html . + /// + /// https://github.com/sandhose/matrix-spec-proposals/blob/msc/sandhose/oidc-discovery/proposals/2965-auth-metadata.md#discovery-via-openid-connect-discovery + @Deprecated('Use [getOidcAuthMetadata] instead.') + Future> getOidcAuthWellKnown(Uri issuer) async { + final requestUri = Uri(path: '/.well-known/openid-configuration'); + final request = Request('GET', issuer.resolveUri(requestUri)); + request.headers['content-type'] = 'application/json'; + final response = await httpClient.send(request); + final responseBody = await response.stream.toBytes(); + if (response.statusCode != 200) { + unexpectedResponse(response, responseBody); + } + final responseString = utf8.decode(responseBody); + return jsonDecode(responseString); + } + /// fallback on OIDC discovery as per MSC 2965 /// /// This can be used along with https://openid.net/specs/openid-connect-discovery-1_0.html . diff --git a/lib/src/client.dart b/lib/src/client.dart index d6a1f74b6..86028b752 100644 --- a/lib/src/client.dart +++ b/lib/src/client.dart @@ -626,8 +626,27 @@ class Client extends MatrixApi { return wellKnown; } finally { + // MSC2965 no longer expects any information on whether OIDC is supported + // to be present in .well-known - the only way to figure out is sadly + // calling the /auth_metadata endpoint. try { - _oidcAuthMetadata = await getOidcAuthMetadata(); + try { + _oidcAuthMetadata = await getOidcAuthMetadata(); + } catch (e) { + Logs().v( + '[OIDC] auth_metadata endpoint not supported. ' + 'Fallback on legacy .well-known discovery.', + e, + ); + // even though no longer required, a homeserver *might* still prefer + // the fallback on .well-known discovery as per + // https://openid.net/specs/openid-connect-discovery-1_0.html + final issuer = + // ignore: deprecated_member_use_from_same_package + _wellKnown?.authentication?.issuer ?? await oidcAuthIssuer(); + // ignore: deprecated_member_use_from_same_package + _oidcAuthMetadata = await getOidcAuthWellKnown(issuer); + } await database?.storeOidcAuthMetadata(_oidcAuthMetadata); Logs().v('[OIDC] Found auth metadata document.'); } catch (e) { From ae260c8bb5f5c23daa14e8283dd3f38b942b2e66 Mon Sep 17 00:00:00 2001 From: The one with the braid Date: Tue, 11 Feb 2025 11:26:15 +0100 Subject: [PATCH 14/17] fix: return .well-known after checking OIDC capabilities Signed-off-by: The one with the braid --- lib/src/client.dart | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/lib/src/client.dart b/lib/src/client.dart index 86028b752..ff0a28493 100644 --- a/lib/src/client.dart +++ b/lib/src/client.dart @@ -616,15 +616,14 @@ class Client extends MatrixApi { /// The result of this call is stored in [wellKnown] for later use at runtime. @override Future getWellknown() async { + DiscoveryInformation wellKnown; try { - final wellKnown = await super.getWellknown(); + wellKnown = await super.getWellknown(); // do not reset the well known here, so super call super.homeserver = wellKnown.mHomeserver.baseUrl.stripTrailingSlash(); _wellKnown = wellKnown; await database?.storeWellKnown(wellKnown); - - return wellKnown; } finally { // MSC2965 no longer expects any information on whether OIDC is supported // to be present in .well-known - the only way to figure out is sadly @@ -632,11 +631,10 @@ class Client extends MatrixApi { try { try { _oidcAuthMetadata = await getOidcAuthMetadata(); - } catch (e) { + } on http.ClientException { Logs().v( '[OIDC] auth_metadata endpoint not supported. ' 'Fallback on legacy .well-known discovery.', - e, ); // even though no longer required, a homeserver *might* still prefer // the fallback on .well-known discovery as per @@ -649,10 +647,11 @@ class Client extends MatrixApi { } await database?.storeOidcAuthMetadata(_oidcAuthMetadata); Logs().v('[OIDC] Found auth metadata document.'); - } catch (e) { - Logs().v('[OIDC] Homeserver does not support OIDC delegation.', e); + } on http.ClientException { + Logs().v('[OIDC] Homeserver does not support OIDC delegation.'); } } + return wellKnown; } /// Checks to see if a username is available, and valid, for the server. From 41ec69779a745163c719539d9b9a6b23c45f3b84 Mon Sep 17 00:00:00 2001 From: The one with the braid Date: Tue, 11 Feb 2025 14:10:33 +0100 Subject: [PATCH 15/17] feat: separate OIDC discovery from .well-known Signed-off-by: The one with the braid --- .../msc2965_oidc_auth_metadata.dart | 49 +++++++++++ lib/src/client.dart | 87 +++++++------------ 2 files changed, 79 insertions(+), 57 deletions(-) diff --git a/lib/msc_extensions/msc_3861_native_oidc/msc2965_oidc_auth_metadata/msc2965_oidc_auth_metadata.dart b/lib/msc_extensions/msc_3861_native_oidc/msc2965_oidc_auth_metadata/msc2965_oidc_auth_metadata.dart index 4cb6d296b..b1baa4524 100644 --- a/lib/msc_extensions/msc_3861_native_oidc/msc2965_oidc_auth_metadata/msc2965_oidc_auth_metadata.dart +++ b/lib/msc_extensions/msc_3861_native_oidc/msc2965_oidc_auth_metadata/msc2965_oidc_auth_metadata.dart @@ -5,6 +5,55 @@ import 'package:http/http.dart' hide Client; import 'package:matrix/matrix.dart'; extension OidcProviderMetadataExtension on Client { + /// High-level function to get the OIDC auth metadata for the homeserver + /// + /// Performs checks on all three revisions of MSC2965 for OIDC discovery. + /// + /// In case the homeserver supports OIDC, this will store the OIDC Auth + /// Metadata provided by the homeserver. + /// + /// This function might usually be called by [checkHomeserver]. Works similar + /// to [getWellknown]. + Future?> getOidcDiscoveryInformation() async { + Map? oidcMetadata; + + // MSC2965 no longer expects any information on whether OIDC is supported + // to be present in .well-known - the only way to figure out is sadly + // calling the /auth_metadata endpoint. + + try { + oidcMetadata = await getOidcAuthMetadata(); + } catch (e) { + Logs().v( + '[OIDC] auth_metadata endpoint not supported. ' + 'Fallback on legacy .well-known discovery.', + e, + ); + } + if (oidcMetadata == null) { + try { + // even though no longer required, a homeserver *might* still prefer + // the fallback on .well-known discovery as per + // https://openid.net/specs/openid-connect-discovery-1_0.html + final issuer = + // ignore: deprecated_member_use_from_same_package + wellKnown?.authentication?.issuer ?? await oidcAuthIssuer(); + // ignore: deprecated_member_use_from_same_package + oidcMetadata = await getOidcAuthWellKnown(issuer); + } catch (e) { + Logs().v('[OIDC] Homeserver does not support OIDC delegation.', e); + } + } + if (oidcMetadata == null) { + return null; + } + + Logs().v('[OIDC] Found auth metadata document.'); + + await database?.storeOidcAuthMetadata(oidcMetadata); + return oidcMetadata; + } + /// Loads the Auth Metadata from the homeserver /// /// Even though homeservers might still use the previous proposed approaches diff --git a/lib/src/client.dart b/lib/src/client.dart index ff0a28493..1f2f286fc 100644 --- a/lib/src/client.dart +++ b/lib/src/client.dart @@ -134,7 +134,8 @@ class Client extends MatrixApi { /// the cached .well-known file updated using [getWellknown] DiscoveryInformation? get wellKnown => _wellKnown; - /// the cached OIDC auth metadata as per MSC 2965 updated using [getWellknown] + /// the cached OIDC auth metadata as per MSC 2965 updated using + /// [getOidcDiscoveryInformation] Map? get oidcAuthMetadata => _oidcAuthMetadata; /// the cached OIDC auth metadata as per MSC 2966 @@ -558,6 +559,7 @@ class Client extends MatrixApi { )> checkHomeserver( Uri homeserverUrl, { bool checkWellKnown = true, + bool checkOidcDiscovery = true, Set? overrideSupportedVersions, }) async { final supportedVersions = @@ -575,15 +577,22 @@ class Client extends MatrixApi { Logs().v('Found no well known information', e); } } + if (checkOidcDiscovery) { + try { + _oidcAuthMetadata = await getOidcDiscoveryInformation(); + } catch (e) { + Logs().v('[OIDC] Error checking OIDC discovery', e); + } + } // Check if server supports at least one supported version final versions = await getVersions(); if (!versions.versions .any((version) => supportedVersions.contains(version))) { - Logs().w( - 'Server supports the versions: ${versions.toString()} but this application is only compatible with ${supportedVersions.toString()}.', + throw BadServerVersionsException( + versions.versions.toSet(), + supportedVersions, ); - assert(false); } final loginTypes = await getLoginFlows() ?? []; @@ -610,47 +619,16 @@ class Client extends MatrixApi { /// Note that this endpoint is not necessarily handled by the homeserver, /// but by another webserver, to be used for discovering the homeserver URL. /// - /// In case the homeserver supports OIDC, this will also request and store - /// the OIDC Auth Metadata provided by the homeserver. - /// /// The result of this call is stored in [wellKnown] for later use at runtime. @override Future getWellknown() async { - DiscoveryInformation wellKnown; - try { - wellKnown = await super.getWellknown(); + final wellKnown = await super.getWellknown(); + + // do not reset the well known here, so super call + super.homeserver = wellKnown.mHomeserver.baseUrl.stripTrailingSlash(); + _wellKnown = wellKnown; + await database?.storeWellKnown(wellKnown); - // do not reset the well known here, so super call - super.homeserver = wellKnown.mHomeserver.baseUrl.stripTrailingSlash(); - _wellKnown = wellKnown; - await database?.storeWellKnown(wellKnown); - } finally { - // MSC2965 no longer expects any information on whether OIDC is supported - // to be present in .well-known - the only way to figure out is sadly - // calling the /auth_metadata endpoint. - try { - try { - _oidcAuthMetadata = await getOidcAuthMetadata(); - } on http.ClientException { - Logs().v( - '[OIDC] auth_metadata endpoint not supported. ' - 'Fallback on legacy .well-known discovery.', - ); - // even though no longer required, a homeserver *might* still prefer - // the fallback on .well-known discovery as per - // https://openid.net/specs/openid-connect-discovery-1_0.html - final issuer = - // ignore: deprecated_member_use_from_same_package - _wellKnown?.authentication?.issuer ?? await oidcAuthIssuer(); - // ignore: deprecated_member_use_from_same_package - _oidcAuthMetadata = await getOidcAuthWellKnown(issuer); - } - await database?.storeOidcAuthMetadata(_oidcAuthMetadata); - Logs().v('[OIDC] Found auth metadata document.'); - } on http.ClientException { - Logs().v('[OIDC] Homeserver does not support OIDC delegation.'); - } - } return wellKnown; } @@ -1704,22 +1682,7 @@ class Client extends MatrixApi { return pushrules != null ? TryGetPushRule.tryFromJson(pushrules) : null; } - static const Set supportedVersions = { - 'v1.1', - 'v1.2', - 'v1.3', - 'v1.4', - 'v1.5', - 'v1.6', - 'v1.7', - 'v1.8', - 'v1.9', - 'v1.10', - 'v1.11', - 'v1.12', - 'v1.13', - }; - + static const Set supportedVersions = {'v1.1', 'v1.2'}; static const List supportedDirectEncryptionAlgorithms = [ AlgorithmTypes.olmV1Curve25519AesSha2, ]; @@ -4110,6 +4073,16 @@ enum SyncStatus { error, } +class BadServerVersionsException implements Exception { + final Set serverVersions, supportedVersions; + + BadServerVersionsException(this.serverVersions, this.supportedVersions); + + @override + String toString() => + 'Server supports the versions: ${serverVersions.toString()} but this application is only compatible with ${supportedVersions.toString()}.'; +} + class BadServerLoginTypesException implements Exception { final Set serverLoginTypes, supportedLoginTypes; From 0ae4e30f511642fc4f80c67b70db3bd422743d28 Mon Sep 17 00:00:00 2001 From: The one with the braid Date: Tue, 11 Feb 2025 19:23:42 +0100 Subject: [PATCH 16/17] fix: avoid rate limits in Bootstrap.askSetupCrossSigning Signed-off-by: The one with the braid --- lib/encryption/utils/bootstrap.dart | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/encryption/utils/bootstrap.dart b/lib/encryption/utils/bootstrap.dart index 163921b1d..4d7a580ca 100644 --- a/lib/encryption/utils/bootstrap.dart +++ b/lib/encryption/utils/bootstrap.dart @@ -494,12 +494,12 @@ class Bootstrap { } } if (newSsssKey != null) { - final storeFutures = >[]; + Logs().v('Store new SSSS key entries...'); + // NOTE(TheOneWithTheBraid): do not use Future.wait due to rate limits + // and token refresh trouble for (final entry in secretsToStore.entries) { - storeFutures.add(newSsssKey!.store(entry.key, entry.value)); + await newSsssKey!.store(entry.key, entry.value); } - Logs().v('Store new SSSS key entries...'); - await Future.wait(storeFutures); } final keysToSign = []; From 5dce7a1d91b6db4b3c93b2a1109d9253c0f9d7bd Mon Sep 17 00:00:00 2001 From: The one with the braid Date: Sun, 16 Feb 2025 12:26:18 +0100 Subject: [PATCH 17/17] fix: ensure to store device IDs for legacy login Signed-off-by: The one with the braid --- lib/src/client.dart | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/src/client.dart b/lib/src/client.dart index 1f2f286fc..06c357e52 100644 --- a/lib/src/client.dart +++ b/lib/src/client.dart @@ -2102,6 +2102,10 @@ class Client extends MatrixApi { olmAccount = newOlmAccount ?? olmAccount; } + if (newDeviceID != null) { + await _database?.storeDeviceId(newDeviceID); + } + // If we are refreshing the session, we are done here: if (onLoginStateChanged.value == LoginState.softLoggedOut) { if (newRefreshToken != null && accessToken != null && userID != null) {