Working with client side auth

This commit is contained in:
Henry Hiles 2025-06-19 16:46:03 -04:00
commit 61ded00870
No known key found for this signature in database
11 changed files with 72 additions and 125 deletions

2
.vscode/launch.json vendored
View file

@ -6,7 +6,7 @@
"configurations": [ "configurations": [
{ {
"name": "matrix-oauth2oidc", "name": "matrix-oauth2oidc",
"program": "bin/matrixgate.dart", "program": "bin/matrixoidc.dart",
"args": [ "args": [
"--homeserver", "--homeserver",
"https://matrix.federated.nexus", "https://matrix.federated.nexus",

View file

@ -1,7 +1,7 @@
import "dart:io"; import "dart:io";
import "package:args/args.dart"; import "package:args/args.dart";
import "package:matrixgate/controllers/settings_controller.dart"; import "package:matrixoidc/controllers/settings_controller.dart";
import "package:matrixgate/helpers/api_helper.dart"; import "package:matrixoidc/helpers/api_helper.dart";
import "package:riverpod/riverpod.dart"; import "package:riverpod/riverpod.dart";
import "package:shelf/shelf.dart"; import "package:shelf/shelf.dart";
import "package:shelf/shelf_io.dart"; import "package:shelf/shelf_io.dart";

View file

@ -1,5 +1,5 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:matrixgate/models/matrix_user.dart"; import "package:matrixoidc/models/matrix_user.dart";
import "package:riverpod/riverpod.dart"; import "package:riverpod/riverpod.dart";
class AuthCodeController extends Notifier<IMap<String, MatrixUser>> { class AuthCodeController extends Notifier<IMap<String, MatrixUser>> {

View file

@ -1,5 +1,5 @@
import "package:args/args.dart"; import "package:args/args.dart";
import "package:matrixgate/models/settings.dart"; import "package:matrixoidc/models/settings.dart";
import "package:riverpod/riverpod.dart"; import "package:riverpod/riverpod.dart";
class SettingsController extends Notifier<Settings?> { class SettingsController extends Notifier<Settings?> {

View file

@ -1,11 +1,11 @@
import "dart:io"; import "dart:io";
import "dart:convert"; import "dart:convert";
import "package:dart_jsonwebtoken/dart_jsonwebtoken.dart"; import "package:dart_jsonwebtoken/dart_jsonwebtoken.dart";
import "package:matrixgate/controllers/auth_code_controller.dart"; import "package:matrixoidc/controllers/auth_code_controller.dart";
import "package:matrixgate/controllers/settings_controller.dart"; import "package:matrixoidc/controllers/settings_controller.dart";
import "package:shelf/shelf.dart"; import "package:shelf/shelf.dart";
import "package:http/http.dart" as http; import "package:http/http.dart" as http;
import "package:matrixgate/models/matrix_user.dart"; import "package:matrixoidc/models/matrix_user.dart";
import "package:riverpod/riverpod.dart"; import "package:riverpod/riverpod.dart";
class ApiHelper { class ApiHelper {
@ -15,74 +15,33 @@ class ApiHelper {
Future<Response> handleLogin(Request request) async { Future<Response> handleLogin(Request request) async {
final body = await request.readAsString(); final body = await request.readAsString();
final data = Uri.splitQueryString(body); final data = Uri.splitQueryString(body);
final settings = ref.read(SettingsController.provider)!;
final username = data["username"]; final userId = data["user_id"];
final password = data["password"]; final accessToken = data["access_token"];
final redirectUri = data["redirect_uri"]; final redirectUri = data["redirect_uri"];
final state = data["state"] ?? ""; final state = data["state"] ?? "";
final clientId = data["client_id"];
final scope = data["scope"];
final nonce = data["nonce"];
if ([ if (userId == null || accessToken == null || redirectUri == null) {
username, return Response(400, body: "Missing parameters");
password,
redirectUri,
clientId,
nonce,
scope,
].any((f) => f == null)) {
return Response(400, body: "Missing required field(s)");
} }
if (!Uri.parse(redirectUri!).host.endsWith(settings.serviceDomain)) { final settings = ref.read(SettingsController.provider)!;
return Response(403, body: "Redirect URI not allowed"); final whoamiRes = await http.get(
} Uri.parse("${settings.homeserver}/_matrix/client/v3/account/whoami"),
final loginRes = await http.post(
Uri.parse("${settings.homeserver}/_matrix/client/v3/login"),
headers: {"Content-Type": "application/json"},
body: json.encode({
"type": "m.login.password",
"identifier": {"type": "m.id.user", "user": username},
"password": password,
}),
);
if (loginRes.statusCode != 200) {
return Response.forbidden("Login failed");
}
final loginData = json.decode(loginRes.body);
final userId = loginData["user_id"];
final accessToken = loginData["access_token"];
final openidRes = await http.post(
Uri.parse(
"${settings.homeserver}/_matrix/client/v3/user/${Uri.encodeComponent(userId)}/openid/request_token",
),
headers: {"Authorization": "Bearer $accessToken"}, headers: {"Authorization": "Bearer $accessToken"},
); );
if (openidRes.statusCode != 200) { if (whoamiRes.statusCode != 200) {
return Response.forbidden( return Response.forbidden("Access token validation failed");
"OpenID request failed, status code ${openidRes.statusCode}",
);
} }
final openidToken = json.decode(openidRes.body)["access_token"];
final code = base64Url.encode( final code = base64Url.encode(
List<int>.generate(16, (_) => DateTime.now().millisecond % 256), List<int>.generate(16, (_) => DateTime.now().millisecond % 256),
); );
ref ref
.read(AuthCodeController.provider.notifier) .read(AuthCodeController.provider.notifier)
.set( .set(code, MatrixUser(userId: userId, matrixToken: accessToken));
code,
MatrixUser(userId: userId, matrixToken: openidToken, nonce: nonce!),
);
return Response.found("$redirectUri?code=$code&state=$state"); return Response.found("$redirectUri?code=$code&state=$state");
} }
@ -111,7 +70,6 @@ class ApiHelper {
final jwt = JWT( final jwt = JWT(
{ {
"nonce": user.nonce,
"exp": "exp":
DateTime.now().add(Duration(days: 7)).millisecondsSinceEpoch ~/ DateTime.now().add(Duration(days: 7)).millisecondsSinceEpoch ~/
1000, 1000,
@ -137,31 +95,26 @@ class ApiHelper {
Future<Response> userinfoHandler(Request request) async { Future<Response> userinfoHandler(Request request) async {
final auth = request.headers["authorization"]; final auth = request.headers["authorization"];
if (auth == null || !auth.startsWith("Bearer ")) { if (auth == null || !auth.startsWith("Bearer ")) {
return Response.forbidden( return Response.forbidden("No token");
json.encode({"error": "missing_token"}),
headers: {"content-type": "application/json"},
);
} }
final token = auth.substring(7); try {
final matrixResp = await http.get( final token = auth.substring(7);
Uri.parse( final jwt = JWT.verify(
"${ref.read(SettingsController.provider)!.homeserver}/_matrix/federation/v1/openid/userinfo", token,
), SecretKey(
headers: {"Authorization": "Bearer $token"}, await File.fromUri(
); Uri.file(ref.read(SettingsController.provider)!.jwtSecretFile),
).readAsString(),
if (matrixResp.statusCode != 200) { ),
return Response.forbidden(
json.encode({"error": "invalid_token"}),
headers: {"content-type": "application/json"},
); );
return Response.ok(
jsonEncode({"sub": jwt.subject}),
headers: {"Content-Type": "application/json"},
);
} catch (e) {
return Response.forbidden("Invalid token");
} }
return Response.ok(
matrixResp.body,
headers: {"content-type": "application/json"},
);
} }
Response jwks(_) => Response.ok( Response jwks(_) => Response.ok(

View file

@ -8,7 +8,6 @@ abstract class MatrixUser with _$MatrixUser {
const factory MatrixUser({ const factory MatrixUser({
required String userId, required String userId,
required String matrixToken, required String matrixToken,
required String nonce,
}) = _MatrixUser; }) = _MatrixUser;
factory MatrixUser.fromJson(Map<String, dynamic> json) => factory MatrixUser.fromJson(Map<String, dynamic> json) =>

View file

@ -16,7 +16,7 @@ T _$identity<T>(T value) => value;
/// @nodoc /// @nodoc
mixin _$MatrixUser { mixin _$MatrixUser {
String get userId; String get matrixToken; String get nonce; String get userId; String get matrixToken;
/// Create a copy of MatrixUser /// Create a copy of MatrixUser
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
@ -29,16 +29,16 @@ $MatrixUserCopyWith<MatrixUser> get copyWith => _$MatrixUserCopyWithImpl<MatrixU
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is MatrixUser&&(identical(other.userId, userId) || other.userId == userId)&&(identical(other.matrixToken, matrixToken) || other.matrixToken == matrixToken)&&(identical(other.nonce, nonce) || other.nonce == nonce)); return identical(this, other) || (other.runtimeType == runtimeType&&other is MatrixUser&&(identical(other.userId, userId) || other.userId == userId)&&(identical(other.matrixToken, matrixToken) || other.matrixToken == matrixToken));
} }
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
@override @override
int get hashCode => Object.hash(runtimeType,userId,matrixToken,nonce); int get hashCode => Object.hash(runtimeType,userId,matrixToken);
@override @override
String toString() { String toString() {
return "MatrixUser(userId: $userId, matrixToken: $matrixToken, nonce: $nonce)"; return "MatrixUser(userId: $userId, matrixToken: $matrixToken)";
} }
@ -49,7 +49,7 @@ abstract mixin class $MatrixUserCopyWith<$Res> {
factory $MatrixUserCopyWith(MatrixUser value, $Res Function(MatrixUser) _then) = _$MatrixUserCopyWithImpl; factory $MatrixUserCopyWith(MatrixUser value, $Res Function(MatrixUser) _then) = _$MatrixUserCopyWithImpl;
@useResult @useResult
$Res call({ $Res call({
String userId, String matrixToken, String nonce String userId, String matrixToken
}); });
@ -66,11 +66,10 @@ class _$MatrixUserCopyWithImpl<$Res>
/// Create a copy of MatrixUser /// Create a copy of MatrixUser
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@pragma("vm:prefer-inline") @override $Res call({Object? userId = null,Object? matrixToken = null,Object? nonce = null,}) { @pragma("vm:prefer-inline") @override $Res call({Object? userId = null,Object? matrixToken = null,}) {
return _then(_self.copyWith( return _then(_self.copyWith(
userId: null == userId ? _self.userId : userId // ignore: cast_nullable_to_non_nullable userId: null == userId ? _self.userId : userId // ignore: cast_nullable_to_non_nullable
as String,matrixToken: null == matrixToken ? _self.matrixToken : matrixToken // ignore: cast_nullable_to_non_nullable as String,matrixToken: null == matrixToken ? _self.matrixToken : matrixToken // ignore: cast_nullable_to_non_nullable
as String,nonce: null == nonce ? _self.nonce : nonce // ignore: cast_nullable_to_non_nullable
as String, as String,
)); ));
} }
@ -82,12 +81,11 @@ as String,
@JsonSerializable() @JsonSerializable()
class _MatrixUser implements MatrixUser { class _MatrixUser implements MatrixUser {
const _MatrixUser({required this.userId, required this.matrixToken, required this.nonce}); const _MatrixUser({required this.userId, required this.matrixToken});
factory _MatrixUser.fromJson(Map<String, dynamic> json) => _$MatrixUserFromJson(json); factory _MatrixUser.fromJson(Map<String, dynamic> json) => _$MatrixUserFromJson(json);
@override final String userId; @override final String userId;
@override final String matrixToken; @override final String matrixToken;
@override final String nonce;
/// Create a copy of MatrixUser /// Create a copy of MatrixUser
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@ -102,16 +100,16 @@ Map<String, dynamic> toJson() {
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _MatrixUser&&(identical(other.userId, userId) || other.userId == userId)&&(identical(other.matrixToken, matrixToken) || other.matrixToken == matrixToken)&&(identical(other.nonce, nonce) || other.nonce == nonce)); return identical(this, other) || (other.runtimeType == runtimeType&&other is _MatrixUser&&(identical(other.userId, userId) || other.userId == userId)&&(identical(other.matrixToken, matrixToken) || other.matrixToken == matrixToken));
} }
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
@override @override
int get hashCode => Object.hash(runtimeType,userId,matrixToken,nonce); int get hashCode => Object.hash(runtimeType,userId,matrixToken);
@override @override
String toString() { String toString() {
return "MatrixUser(userId: $userId, matrixToken: $matrixToken, nonce: $nonce)"; return "MatrixUser(userId: $userId, matrixToken: $matrixToken)";
} }
@ -122,7 +120,7 @@ abstract mixin class _$MatrixUserCopyWith<$Res> implements $MatrixUserCopyWith<$
factory _$MatrixUserCopyWith(_MatrixUser value, $Res Function(_MatrixUser) _then) = __$MatrixUserCopyWithImpl; factory _$MatrixUserCopyWith(_MatrixUser value, $Res Function(_MatrixUser) _then) = __$MatrixUserCopyWithImpl;
@override @useResult @override @useResult
$Res call({ $Res call({
String userId, String matrixToken, String nonce String userId, String matrixToken
}); });
@ -139,11 +137,10 @@ class __$MatrixUserCopyWithImpl<$Res>
/// Create a copy of MatrixUser /// Create a copy of MatrixUser
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@override @pragma("vm:prefer-inline") $Res call({Object? userId = null,Object? matrixToken = null,Object? nonce = null,}) { @override @pragma("vm:prefer-inline") $Res call({Object? userId = null,Object? matrixToken = null,}) {
return _then(_MatrixUser( return _then(_MatrixUser(
userId: null == userId ? _self.userId : userId // ignore: cast_nullable_to_non_nullable userId: null == userId ? _self.userId : userId // ignore: cast_nullable_to_non_nullable
as String,matrixToken: null == matrixToken ? _self.matrixToken : matrixToken // ignore: cast_nullable_to_non_nullable as String,matrixToken: null == matrixToken ? _self.matrixToken : matrixToken // ignore: cast_nullable_to_non_nullable
as String,nonce: null == nonce ? _self.nonce : nonce // ignore: cast_nullable_to_non_nullable
as String, as String,
)); ));
} }

View file

@ -9,12 +9,10 @@ part of "matrix_user.dart";
_MatrixUser _$MatrixUserFromJson(Map<String, dynamic> json) => _MatrixUser( _MatrixUser _$MatrixUserFromJson(Map<String, dynamic> json) => _MatrixUser(
userId: json["userId"] as String, userId: json["userId"] as String,
matrixToken: json["matrixToken"] as String, matrixToken: json["matrixToken"] as String,
nonce: json["nonce"] as String,
); );
Map<String, dynamic> _$MatrixUserToJson(_MatrixUser instance) => Map<String, dynamic> _$MatrixUserToJson(_MatrixUser instance) =>
<String, dynamic>{ <String, dynamic>{
"userId": instance.userId, "userId": instance.userId,
"matrixToken": instance.matrixToken, "matrixToken": instance.matrixToken,
"nonce": instance.nonce,
}; };

View file

@ -4,7 +4,7 @@
// ignore_for_file: type=lint // ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark // ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'settings.dart'; part of "settings.dart";
// ************************************************************************** // **************************************************************************
// FreezedGenerator // FreezedGenerator
@ -20,7 +20,7 @@ mixin _$Settings {
/// Create a copy of Settings /// Create a copy of Settings
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline') @pragma("vm:prefer-inline")
$SettingsCopyWith<Settings> get copyWith => _$SettingsCopyWithImpl<Settings>(this as Settings, _$identity); $SettingsCopyWith<Settings> get copyWith => _$SettingsCopyWithImpl<Settings>(this as Settings, _$identity);
/// Serializes this Settings to a JSON map. /// Serializes this Settings to a JSON map.
@ -38,7 +38,7 @@ int get hashCode => Object.hash(runtimeType,socket,address,port,homeserver,issue
@override @override
String toString() { String toString() {
return 'Settings(socket: $socket, address: $address, port: $port, homeserver: $homeserver, issuer: $issuer, serviceDomain: $serviceDomain, jwtSecretFile: $jwtSecretFile, authorizeEndpoint: $authorizeEndpoint)'; return "Settings(socket: $socket, address: $address, port: $port, homeserver: $homeserver, issuer: $issuer, serviceDomain: $serviceDomain, jwtSecretFile: $jwtSecretFile, authorizeEndpoint: $authorizeEndpoint)";
} }
@ -66,7 +66,7 @@ class _$SettingsCopyWithImpl<$Res>
/// Create a copy of Settings /// Create a copy of Settings
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? socket = freezed,Object? address = null,Object? port = null,Object? homeserver = null,Object? issuer = null,Object? serviceDomain = null,Object? jwtSecretFile = null,Object? authorizeEndpoint = null,}) { @pragma("vm:prefer-inline") @override $Res call({Object? socket = freezed,Object? address = null,Object? port = null,Object? homeserver = null,Object? issuer = null,Object? serviceDomain = null,Object? jwtSecretFile = null,Object? authorizeEndpoint = null,}) {
return _then(_self.copyWith( return _then(_self.copyWith(
socket: freezed == socket ? _self.socket : socket // ignore: cast_nullable_to_non_nullable socket: freezed == socket ? _self.socket : socket // ignore: cast_nullable_to_non_nullable
as String?,address: null == address ? _self.address : address // ignore: cast_nullable_to_non_nullable as String?,address: null == address ? _self.address : address // ignore: cast_nullable_to_non_nullable
@ -102,7 +102,7 @@ class _Settings implements Settings {
/// Create a copy of Settings /// Create a copy of Settings
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false) @override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline') @pragma("vm:prefer-inline")
_$SettingsCopyWith<_Settings> get copyWith => __$SettingsCopyWithImpl<_Settings>(this, _$identity); _$SettingsCopyWith<_Settings> get copyWith => __$SettingsCopyWithImpl<_Settings>(this, _$identity);
@override @override
@ -121,7 +121,7 @@ int get hashCode => Object.hash(runtimeType,socket,address,port,homeserver,issue
@override @override
String toString() { String toString() {
return 'Settings(socket: $socket, address: $address, port: $port, homeserver: $homeserver, issuer: $issuer, serviceDomain: $serviceDomain, jwtSecretFile: $jwtSecretFile, authorizeEndpoint: $authorizeEndpoint)'; return "Settings(socket: $socket, address: $address, port: $port, homeserver: $homeserver, issuer: $issuer, serviceDomain: $serviceDomain, jwtSecretFile: $jwtSecretFile, authorizeEndpoint: $authorizeEndpoint)";
} }
@ -149,7 +149,7 @@ class __$SettingsCopyWithImpl<$Res>
/// Create a copy of Settings /// Create a copy of Settings
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? socket = freezed,Object? address = null,Object? port = null,Object? homeserver = null,Object? issuer = null,Object? serviceDomain = null,Object? jwtSecretFile = null,Object? authorizeEndpoint = null,}) { @override @pragma("vm:prefer-inline") $Res call({Object? socket = freezed,Object? address = null,Object? port = null,Object? homeserver = null,Object? issuer = null,Object? serviceDomain = null,Object? jwtSecretFile = null,Object? authorizeEndpoint = null,}) {
return _then(_Settings( return _then(_Settings(
socket: freezed == socket ? _self.socket : socket // ignore: cast_nullable_to_non_nullable socket: freezed == socket ? _self.socket : socket // ignore: cast_nullable_to_non_nullable
as String?,address: null == address ? _self.address : address // ignore: cast_nullable_to_non_nullable as String?,address: null == address ? _self.address : address // ignore: cast_nullable_to_non_nullable

View file

@ -1,29 +1,29 @@
// GENERATED CODE - DO NOT MODIFY BY HAND // GENERATED CODE - DO NOT MODIFY BY HAND
part of 'settings.dart'; part of "settings.dart";
// ************************************************************************** // **************************************************************************
// JsonSerializableGenerator // JsonSerializableGenerator
// ************************************************************************** // **************************************************************************
_Settings _$SettingsFromJson(Map<String, dynamic> json) => _Settings( _Settings _$SettingsFromJson(Map<String, dynamic> json) => _Settings(
socket: json['socket'] as String?, socket: json["socket"] as String?,
address: json['address'] as String, address: json["address"] as String,
port: json['port'] as String, port: json["port"] as String,
homeserver: json['homeserver'] as String, homeserver: json["homeserver"] as String,
issuer: json['issuer'] as String, issuer: json["issuer"] as String,
serviceDomain: json['serviceDomain'] as String, serviceDomain: json["serviceDomain"] as String,
jwtSecretFile: json['jwtSecretFile'] as String, jwtSecretFile: json["jwtSecretFile"] as String,
authorizeEndpoint: json['authorizeEndpoint'] as String, authorizeEndpoint: json["authorizeEndpoint"] as String,
); );
Map<String, dynamic> _$SettingsToJson(_Settings instance) => <String, dynamic>{ Map<String, dynamic> _$SettingsToJson(_Settings instance) => <String, dynamic>{
'socket': instance.socket, "socket": instance.socket,
'address': instance.address, "address": instance.address,
'port': instance.port, "port": instance.port,
'homeserver': instance.homeserver, "homeserver": instance.homeserver,
'issuer': instance.issuer, "issuer": instance.issuer,
'serviceDomain': instance.serviceDomain, "serviceDomain": instance.serviceDomain,
'jwtSecretFile': instance.jwtSecretFile, "jwtSecretFile": instance.jwtSecretFile,
'authorizeEndpoint': instance.authorizeEndpoint, "authorizeEndpoint": instance.authorizeEndpoint,
}; };

View file

@ -1,5 +1,5 @@
name: matrixgate name: matrixoidc
description: A minimal OpenID Connect provider backed by Matrix OpenID tokens. description: A minimal OpenID Connect provider backed by Matrix.
version: 1.0.0 version: 1.0.0
publish_to: none publish_to: none