Skip to content
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

BREAKING: implement Native OIDC as per MSC 3861 #2024

Open
wants to merge 16 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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