Skip to content
New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

BREAKING: implement Native OIDC as per MSC 3861 #2024

Open
wants to merge 17 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
f6fc351
BREAKING: implement Native OIDC as per MSC 3861
TheOneWithTheBraid Feb 7, 2025
5034107
fix: wrong response_type parameter
TheOneWithTheBraid Feb 7, 2025
817186c
feat: implement a callback to hand over the OAuth2.0 authorization ur…
TheOneWithTheBraid Feb 7, 2025
989b7bf
feat: add parameter to enforce new dynamic client registration
TheOneWithTheBraid Feb 8, 2025
f92cb8c
fix: still fetch auth metadata when well known is not served
TheOneWithTheBraid Feb 8, 2025
bc31297
feat: improve documentation
TheOneWithTheBraid Feb 8, 2025
b6df008
fix: include scope into oauth2 redirect
TheOneWithTheBraid Feb 8, 2025
df16d0c
fix: revert unwanted change in Client
TheOneWithTheBraid Feb 8, 2025
2555f74
fix: properly calculate PKCE for OAuth2 token exchange
TheOneWithTheBraid Feb 8, 2025
15707b9
fix: correctly assign request form body for OAuth2 token requests
TheOneWithTheBraid Feb 8, 2025
c00e51e
fix: migration of device ID to individual database entry
TheOneWithTheBraid Feb 8, 2025
24a81eb
chore: better parse redirect uri query
TheOneWithTheBraid Feb 9, 2025
3e45d9a
feat: support all possible methods for OIDC discovery
TheOneWithTheBraid Feb 9, 2025
ae260c8
fix: return .well-known after checking OIDC capabilities
TheOneWithTheBraid Feb 11, 2025
41ec697
feat: separate OIDC discovery from .well-known
TheOneWithTheBraid Feb 11, 2025
0ae4e30
fix: avoid rate limits in Bootstrap.askSetupCrossSigning
TheOneWithTheBraid Feb 11, 2025
5dce7a1
fix: ensure to store device IDs for legacy login
TheOneWithTheBraid Feb 16, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions lib/encryption/utils/bootstrap.dart
Original file line number Diff line number Diff line change
Expand Up @@ -494,12 +494,12 @@ class Bootstrap {
}
}
if (newSsssKey != null) {
final storeFutures = <Future<void>>[];
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 = <SignableKey>[];
Expand Down
1 change: 1 addition & 0 deletions lib/matrix.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
10 changes: 9 additions & 1 deletion lib/msc_extensions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
- `io.element.recent_emoji` - recent emoji sync in account data
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import 'dart:math';

import 'package:matrix/matrix.dart';

extension GenerateDeviceIdExtension on Client {
/// MSC 2964 & MSC 2967
Future<String> 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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,310 @@
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<void> oidcAuthorizationGrantFlow({
required Completer<OidcCallbackResponse> nativeCompleter,
required String oidcClientId,
required Uri redirectUri,
required String responseMode,
required void Function(Uri oauth2uri) launchOAuth2Uri,
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 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);
rethrow;
}

// generate the OAuth2 uri to authenticate at the IDP
final uri = await oidcMakeOAuth2Uri(
authorizationEndpoint: authEndpoint,
oidcClientId: oidcClientId,
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,
);
// 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;

// 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,
);
}

/// Computes an OAuth2 flow authorization Uri
///
/// - generates the challenge for the `codeVerifier` as per RFC 7636
/// - 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<Uri> oidcMakeOAuth2Uri({
required Uri authorizationEndpoint,
required String oidcClientId,
required Uri redirectUri,
required List<String> 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(ascii.encode(codeVerifier));
final encodedChallenge = base64UrlEncode(codeChallenge);

final requestUri = authorizationEndpoint.replace(
queryParameters: {
'client_id': oidcClientId,
'response_type': 'code',
'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':
// remove the "=" padding
encodedChallenge.substring(0, encodedChallenge.length - 1),
'code_challenge_method': 'S256',
},
);
return requestUri;
}

/// 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<OidcTokenResponse> oidcRequestToken({
required Uri tokenEndpoint,
required String oidcClientId,
required String oAuth2Code,
required Uri redirectUri,
required String codeVerifier,
}) async {
final request = Request('POST', tokenEndpoint);
request.bodyFields = {
'grant_type': 'authorization_code',
'code': oAuth2Code,
'redirect_uri': redirectUri.toString(),
'client_id': oidcClientId,
'code_verifier': codeVerifier,
};
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<OidcTokenResponse> oidcRefreshToken({
required Uri tokenEndpoint,
required String refreshToken,
required String oidcClientId,
}) async {
final request = Request('POST', tokenEndpoint);
request.bodyFields = {
'grant_type': 'refresh_token',
'refresh_token': refreshToken,
'client_id': oidcClientId,
};
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',
]) {
Uri search;
// parse either fragment or query into Uri for easier search handling
if (responseMode == 'fragment') {
search = Uri(query: Uri.parse(redirectUri).fragment);
} else if (responseMode == 'query') {
search = Uri(query: Uri.parse(redirectUri).query);
} else {
search = Uri.parse(redirectUri);
}
return OidcCallbackResponse(
search.queryParameters['state']!,
code: search.queryParameters['code'],
error: search.queryParameters['error'],
errorDescription: search.queryParameters['error_description'],
errorUri: search.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<String> scope;

const OidcTokenResponse({
required this.accessToken,
required this.tokenType,
required this.expiresIn,
required this.refreshToken,
required this.scope,
});

factory OidcTokenResponse.fromJson(Map<String, Object?> 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<String, Object?> toJson() => {
'access_token': accessToken,
'token_type': tokenType,
'expires_in': expiresIn,
'refresh_token': refreshToken,
'scope': scope.join(' '),
};
}
Loading