1
0
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:
Tin Pecirep
2025-04-23 16:05:00 +02:00
committed by Zack Pollard
parent 13d6bd67b1
commit b7a0cf2470
18 changed files with 469 additions and 192 deletions

View File

@ -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,
),
);
}

View File

@ -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;

View File

@ -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',
};
}

View File

@ -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',
};
}

View File

@ -303,7 +303,7 @@ packages:
source: hosted
version: "0.3.4+2"
crypto:
dependency: transitive
dependency: "direct main"
description:
name: crypto
sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855"

View File

@ -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