-
Notifications
You must be signed in to change notification settings - Fork 47
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
base: main
Are you sure you want to change the base?
Changes from all commits
65bee78
ff058ca
031de19
e795ed5
2250d13
9baaab3
b08a7c7
81327c1
2b78575
9a1238f
ed68e6e
d506fa4
11a1975
2d16b7e
0d064bb
c25b499
dee240c
d78c4f3
48bb3b3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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(); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
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({ | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If the prompt doesn't match, do we want to continue? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. (E.g. a client could hard-code the two prompts There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can you make sure to remove ALL the padding? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Dart's |
||
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(' '), | ||
}; | ||
} |
Uh oh!
There was an error while loading. Please reload this page.