You've already forked immich
mirror of
https://github.com/immich-app/immich.git
synced 2025-08-08 23:07:06 +02:00
feat: add oauth2 code verifier
* fix: ensure oauth state param matches before finishing oauth flow Signed-off-by: Tin Pecirep <tin.pecirep@gmail.com> * chore: upgrade openid-client to v6 Signed-off-by: Tin Pecirep <tin.pecirep@gmail.com> * feat: use PKCE for oauth2 on supported clients Signed-off-by: Tin Pecirep <tin.pecirep@gmail.com> * feat: use state and PKCE in mobile app Signed-off-by: Tin Pecirep <tin.pecirep@gmail.com> * fix: remove obsolete oauth repository init Signed-off-by: Tin Pecirep <tin.pecirep@gmail.com> * fix: rewrite callback url if mobile redirect url is enabled Signed-off-by: Tin Pecirep <tin.pecirep@gmail.com> * fix: propagate oidc client error cause when oauth callback fails Signed-off-by: Tin Pecirep <tin.pecirep@gmail.com> * fix: adapt auth service tests to required state and PKCE params Signed-off-by: Tin Pecirep <tin.pecirep@gmail.com> * fix: update sdk types Signed-off-by: Tin Pecirep <tin.pecirep@gmail.com> * fix: adapt oauth e2e test to work with PKCE Signed-off-by: Tin Pecirep <tin.pecirep@gmail.com> * fix: allow insecure (http) oauth clients Signed-off-by: Tin Pecirep <tin.pecirep@gmail.com> --------- Signed-off-by: Tin Pecirep <tin.pecirep@gmail.com> Co-authored-by: Jason Rasmussen <jason@rasm.me>
This commit is contained in:
committed by
Zack Pollard
parent
13d6bd67b1
commit
b7a0cf2470
@ -13,6 +13,8 @@ class OAuthService {
|
||||
|
||||
Future<String?> getOAuthServerUrl(
|
||||
String serverUrl,
|
||||
String state,
|
||||
String codeChallenge,
|
||||
) async {
|
||||
// Resolve API server endpoint from user provided serverUrl
|
||||
await _apiService.resolveAndSetEndpoint(serverUrl);
|
||||
@ -22,7 +24,11 @@ class OAuthService {
|
||||
);
|
||||
|
||||
final dto = await _apiService.oAuthApi.startOAuth(
|
||||
OAuthConfigDto(redirectUri: redirectUri),
|
||||
OAuthConfigDto(
|
||||
redirectUri: redirectUri,
|
||||
state: state,
|
||||
codeChallenge: codeChallenge,
|
||||
),
|
||||
);
|
||||
|
||||
final authUrl = dto?.url;
|
||||
@ -31,7 +37,11 @@ class OAuthService {
|
||||
return authUrl;
|
||||
}
|
||||
|
||||
Future<LoginResponseDto?> oAuthLogin(String oauthUrl) async {
|
||||
Future<LoginResponseDto?> oAuthLogin(
|
||||
String oauthUrl,
|
||||
String state,
|
||||
String codeVerifier,
|
||||
) async {
|
||||
String result = await FlutterWebAuth2.authenticate(
|
||||
url: oauthUrl,
|
||||
callbackUrlScheme: callbackUrlScheme,
|
||||
@ -49,6 +59,8 @@ class OAuthService {
|
||||
return await _apiService.oAuthApi.finishOAuth(
|
||||
OAuthCallbackDto(
|
||||
url: result,
|
||||
state: state,
|
||||
codeVerifier: codeVerifier,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@ -1,6 +1,9 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
@ -203,13 +206,32 @@ class LoginForm extends HookConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
String generateRandomString(int length) {
|
||||
final random = Random.secure();
|
||||
return base64Url
|
||||
.encode(List<int>.generate(32, (i) => random.nextInt(256)));
|
||||
}
|
||||
|
||||
Future<String> generatePKCECodeChallenge(String codeVerifier) async {
|
||||
var bytes = utf8.encode(codeVerifier);
|
||||
var digest = sha256.convert(bytes);
|
||||
return base64Url.encode(digest.bytes).replaceAll('=', '');
|
||||
}
|
||||
|
||||
oAuthLogin() async {
|
||||
var oAuthService = ref.watch(oAuthServiceProvider);
|
||||
String? oAuthServerUrl;
|
||||
|
||||
final state = generateRandomString(32);
|
||||
final codeVerifier = generateRandomString(64);
|
||||
final codeChallenge = await generatePKCECodeChallenge(codeVerifier);
|
||||
|
||||
try {
|
||||
oAuthServerUrl = await oAuthService
|
||||
.getOAuthServerUrl(sanitizeUrl(serverEndpointController.text));
|
||||
oAuthServerUrl = await oAuthService.getOAuthServerUrl(
|
||||
sanitizeUrl(serverEndpointController.text),
|
||||
state,
|
||||
codeChallenge,
|
||||
);
|
||||
|
||||
isLoading.value = true;
|
||||
|
||||
@ -230,8 +252,11 @@ class LoginForm extends HookConsumerWidget {
|
||||
|
||||
if (oAuthServerUrl != null) {
|
||||
try {
|
||||
final loginResponseDto =
|
||||
await oAuthService.oAuthLogin(oAuthServerUrl);
|
||||
final loginResponseDto = await oAuthService.oAuthLogin(
|
||||
oAuthServerUrl,
|
||||
state,
|
||||
codeVerifier,
|
||||
);
|
||||
|
||||
if (loginResponseDto == null) {
|
||||
return;
|
||||
|
43
mobile/openapi/lib/model/o_auth_callback_dto.dart
generated
43
mobile/openapi/lib/model/o_auth_callback_dto.dart
generated
@ -14,25 +14,36 @@ class OAuthCallbackDto {
|
||||
/// Returns a new [OAuthCallbackDto] instance.
|
||||
OAuthCallbackDto({
|
||||
required this.url,
|
||||
required this.state,
|
||||
required this.codeVerifier,
|
||||
});
|
||||
|
||||
String url;
|
||||
String state;
|
||||
String codeVerifier;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is OAuthCallbackDto &&
|
||||
other.url == url;
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is OAuthCallbackDto &&
|
||||
other.url == url &&
|
||||
other.state == state &&
|
||||
other.codeVerifier == codeVerifier;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(url.hashCode);
|
||||
// ignore: unnecessary_parenthesis
|
||||
(url.hashCode) + (state.hashCode) + (codeVerifier.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'OAuthCallbackDto[url=$url]';
|
||||
String toString() =>
|
||||
'OAuthCallbackDto[url=$url, state=$state, codeVerifier=$codeVerifier]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'url'] = this.url;
|
||||
json[r'url'] = this.url;
|
||||
json[r'state'] = this.state;
|
||||
json[r'codeVerifier'] = this.codeVerifier;
|
||||
return json;
|
||||
}
|
||||
|
||||
@ -46,12 +57,17 @@ class OAuthCallbackDto {
|
||||
|
||||
return OAuthCallbackDto(
|
||||
url: mapValueOfType<String>(json, r'url')!,
|
||||
state: mapValueOfType<String>(json, r'state')!,
|
||||
codeVerifier: mapValueOfType<String>(json, r'codeVerifier')!,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<OAuthCallbackDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
static List<OAuthCallbackDto> listFromJson(
|
||||
dynamic json, {
|
||||
bool growable = false,
|
||||
}) {
|
||||
final result = <OAuthCallbackDto>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
@ -79,13 +95,19 @@ class OAuthCallbackDto {
|
||||
}
|
||||
|
||||
// maps a json object with a list of OAuthCallbackDto-objects as value to a dart map
|
||||
static Map<String, List<OAuthCallbackDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
static Map<String, List<OAuthCallbackDto>> mapListFromJson(
|
||||
dynamic json, {
|
||||
bool growable = false,
|
||||
}) {
|
||||
final map = <String, List<OAuthCallbackDto>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = OAuthCallbackDto.listFromJson(entry.value, growable: growable,);
|
||||
map[entry.key] = OAuthCallbackDto.listFromJson(
|
||||
entry.value,
|
||||
growable: growable,
|
||||
);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
@ -94,6 +116,7 @@ class OAuthCallbackDto {
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'url',
|
||||
'state',
|
||||
'codeVerifier',
|
||||
};
|
||||
}
|
||||
|
||||
|
43
mobile/openapi/lib/model/o_auth_config_dto.dart
generated
43
mobile/openapi/lib/model/o_auth_config_dto.dart
generated
@ -14,25 +14,36 @@ class OAuthConfigDto {
|
||||
/// Returns a new [OAuthConfigDto] instance.
|
||||
OAuthConfigDto({
|
||||
required this.redirectUri,
|
||||
required this.state,
|
||||
required this.codeChallenge,
|
||||
});
|
||||
|
||||
String redirectUri;
|
||||
String state;
|
||||
String codeChallenge;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is OAuthConfigDto &&
|
||||
other.redirectUri == redirectUri;
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is OAuthConfigDto &&
|
||||
other.redirectUri == redirectUri &&
|
||||
other.state == state &&
|
||||
other.codeChallenge == codeChallenge;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(redirectUri.hashCode);
|
||||
// ignore: unnecessary_parenthesis
|
||||
(redirectUri.hashCode) + (state.hashCode) + (codeChallenge.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'OAuthConfigDto[redirectUri=$redirectUri]';
|
||||
String toString() =>
|
||||
'OAuthConfigDto[redirectUri=$redirectUri, state=$state, codeChallenge=$codeChallenge]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'redirectUri'] = this.redirectUri;
|
||||
json[r'redirectUri'] = this.redirectUri;
|
||||
json[r'state'] = this.state;
|
||||
json[r'codeChallenge'] = this.codeChallenge;
|
||||
return json;
|
||||
}
|
||||
|
||||
@ -46,12 +57,17 @@ class OAuthConfigDto {
|
||||
|
||||
return OAuthConfigDto(
|
||||
redirectUri: mapValueOfType<String>(json, r'redirectUri')!,
|
||||
state: mapValueOfType<String>(json, r'state')!,
|
||||
codeChallenge: mapValueOfType<String>(json, r'codeChallenge')!,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<OAuthConfigDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
static List<OAuthConfigDto> listFromJson(
|
||||
dynamic json, {
|
||||
bool growable = false,
|
||||
}) {
|
||||
final result = <OAuthConfigDto>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
@ -79,13 +95,19 @@ class OAuthConfigDto {
|
||||
}
|
||||
|
||||
// maps a json object with a list of OAuthConfigDto-objects as value to a dart map
|
||||
static Map<String, List<OAuthConfigDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
static Map<String, List<OAuthConfigDto>> mapListFromJson(
|
||||
dynamic json, {
|
||||
bool growable = false,
|
||||
}) {
|
||||
final map = <String, List<OAuthConfigDto>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = OAuthConfigDto.listFromJson(entry.value, growable: growable,);
|
||||
map[entry.key] = OAuthConfigDto.listFromJson(
|
||||
entry.value,
|
||||
growable: growable,
|
||||
);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
@ -94,6 +116,7 @@ class OAuthConfigDto {
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'redirectUri',
|
||||
'state',
|
||||
'codeChallenge',
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -303,7 +303,7 @@ packages:
|
||||
source: hosted
|
||||
version: "0.3.4+2"
|
||||
crypto:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: crypto
|
||||
sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855"
|
||||
|
@ -22,6 +22,7 @@ dependencies:
|
||||
collection: ^1.18.0
|
||||
connectivity_plus: ^6.1.3
|
||||
crop_image: ^1.0.16
|
||||
crypto: ^3.0.6
|
||||
device_info_plus: ^11.3.3
|
||||
dynamic_color: ^1.7.0
|
||||
easy_image_viewer: ^1.5.1
|
||||
|
Reference in New Issue
Block a user