Skip to content

BREAKING: implement Native OIDC as per MSC 3861 #2024

New issue

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

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

Already on GitHub? Sign in to your account

Open
wants to merge 19 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
65bee78
BREAKING: implement Native OIDC as per MSC 3861
TheOneWithTheBraid Feb 7, 2025
ff058ca
fix: wrong response_type parameter
TheOneWithTheBraid Feb 7, 2025
031de19
feat: implement a callback to hand over the OAuth2.0 authorization ur…
TheOneWithTheBraid Feb 7, 2025
e795ed5
feat: add parameter to enforce new dynamic client registration
TheOneWithTheBraid Feb 8, 2025
2250d13
fix: still fetch auth metadata when well known is not served
TheOneWithTheBraid Feb 8, 2025
9baaab3
feat: improve documentation
TheOneWithTheBraid Feb 8, 2025
b08a7c7
fix: include scope into oauth2 redirect
TheOneWithTheBraid Feb 8, 2025
81327c1
fix: revert unwanted change in Client
TheOneWithTheBraid Feb 8, 2025
2b78575
fix: properly calculate PKCE for OAuth2 token exchange
TheOneWithTheBraid Feb 8, 2025
9a1238f
fix: correctly assign request form body for OAuth2 token requests
TheOneWithTheBraid Feb 8, 2025
ed68e6e
fix: migration of device ID to individual database entry
TheOneWithTheBraid Feb 8, 2025
d506fa4
chore: better parse redirect uri query
TheOneWithTheBraid Feb 9, 2025
11a1975
feat: support all possible methods for OIDC discovery
TheOneWithTheBraid Feb 9, 2025
2d16b7e
fix: return .well-known after checking OIDC capabilities
TheOneWithTheBraid Feb 11, 2025
0d064bb
feat: separate OIDC discovery from .well-known
TheOneWithTheBraid Feb 11, 2025
c25b499
fix: avoid rate limits in Bootstrap.askSetupCrossSigning
TheOneWithTheBraid Feb 11, 2025
dee240c
fix: ensure to store device IDs for legacy login
TheOneWithTheBraid Feb 16, 2025
d78c4f3
fix: non-nullable database in Client.init
TheOneWithTheBraid May 20, 2025
48bb3b3
fix: clean up bad version response
TheOneWithTheBraid May 20, 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 @@ -469,12 +469,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 'msc_extensions/extension_timeline_export/timeline_export.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 {
Copy link
Contributor

@krille-chan krille-chan Jul 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add more comments what this does? The method name is super unintuitive. Links to the MSCs would also be more helpful than MSC numbers.

if (!enforceNewDevice) {
final storedDeviceId = await database?.getDeviceId();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need the stored device ID and can't just use Client.deviceID? It should be the same

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({
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The method name says nothing about what it does? No idea what this is flow. Please switch this to a proper name with dart docs

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) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the prompt doesn't match, do we want to continue?

Copy link
Contributor Author

@TheOneWithTheBraid TheOneWithTheBraid May 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. A client might request a prompt the server does not know. Using this handler here, the authentication server will fall back on the default selection showing all possible ways to proceed. The only behavior we need to avoid is passing an invalid prompt since this would cause the authentication server to show an error screen.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(E.g. a client could hard-code the two prompts login and register. In this case the SDK takes care of not prompting an invalid option to the MAS if e.g. registration is disabled.)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In fact, it's a pure convenience feature making client implementation easier and more reliable.

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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you make sure to remove ALL the padding?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dart's base64 codec only adds the trailing = as padding, doesn't it ? Any suggestions how to better handle the padding ? I'm a bit clueless on how to do this without over engineering the implementation. Help appreciated.

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