Skip to content

Commit

Permalink
feat: OIDC support (#281)
Browse files Browse the repository at this point in the history
  • Loading branch information
TomBursch authored Nov 15, 2023
1 parent 8777822 commit 049f5cf
Show file tree
Hide file tree
Showing 22 changed files with 609 additions and 37 deletions.
Binary file added assets/images/google_logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 1 addition & 4 deletions default.conf.template
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,7 @@ server {

location / {
client_max_body_size 32M;
try_files $uri $uri/ @redirect;
}
location @redirect {
return 301 "/#/404";
try_files $uri $uri/ /index.html;
}
location /api/ {
include uwsgi_params;
Expand Down
Binary file added docs/img/screenshots/oidc_button.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 4 additions & 2 deletions docs/self-hosting/advanced.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,23 @@ There are a few options for advanced users. Customization is done using environm
### Tags

There are three tags available: `latest`, `beta` and `dev`. `latest` is the most current stable release and is the default. `beta` corresponds to the most recent prerelease and might have some experimental features. The `dev` tag is directly build from the main branch and should not be used in production. Release notes can be found on the [releases page](https://github.com/TomBursch/kitchenowl/releases).
Additionally, the releases are tagged, so you can always choose a specific version with `vXX` for the backend or `vX.X.X` for the frontend.

### Frontend

Environment variables for `tombursch/kitchenowl-web`:

- `BACK_URL` (default: `back:5000`): Allows to set a custom address for the backend. Needs to be an uWSGI protocol endpoint. Usually corresponds to the name and of the backend container and port `5000`.
- `BACK_URL` (default: `back:5000`): Allows to set a custom address for the backend. Needs to be an uWSGI protocol endpoint. Should correspond to the name or IP of the backend container and port `5000`.

### Backend

Environment variables for `tombursch/kitchenowl`:

- `FRONT_URL`: Adds allow origin CORS header for the URL.
- `FRONT_URL`: Adds allow origin CORS header for the URL. If set, should exactly match KitchenOwl's URL including the schema (e.g. `https://app.kitchenowl.org`)
- `PRIVACY_POLICY_URL`: Allows to set a custom privacy policy for your server instance.
- `OPEN_REGISTRATION` (default: `false`): If set allows anyone to create an account on your server.
- `EMAIL_MANDATORY` (default: `false`): Make the email a mandatory field when registering (Only relevant if `OPEN_REGISTRATION` is set)
- Set up with OpenID Connect: [OIDC](./oidc.md)
- Set up with a PostgreSQL database: [docker-compose.yml](https://github.com/TomBursch/kitchenowl-backend/blob/main/docker-compose-postgres.yml)

Additionally, to setting these environment variables you can also override the start command to scale the backend up.
Expand Down
61 changes: 61 additions & 0 deletions docs/self-hosting/oidc.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# OpenID Connect

OIDC allow users to sign in with social logins or third party issuer. KitchenOwl supports three providers: Google, Apple (only on iOS & macOS), and a custom one.

For self-hosted instances the custom provider is the most interesting one.

### Setup
Inside your OIDC you need to configure a new client, with the following to redirect URIs:

<div class="annotate" markdown>
- `FRONT_URL(1)/signin/redirect`
- `kitchenowl:///signin/redirect`
</div>

1. FRONT_URL is the environment variable that exactly matches KitchenOwl's URL including the schema (e.g. `https://app.kitchenowl.org`)

KitchenOwl will request the following scopes:

- `openid`
- `profile`
- `email`

You can then configure the backend using environment variables, just provide your issuer URL, client ID, and client secret:

```yaml
back:
environment:
- [...]
- FRONT_URL=<URL> # front_url is requred when using oidc
- OIDC_ISSUER=<URL> # e.g https://accounts.google.com
- OIDC_CLIENT_ID=<ID>
- OIDC_CLIENT_SECRET=<SECRET>
```
If everything is set up correctly you should see a *sign in with OIDC* button at the bottom of the login page.
![screenshot](/img/screenshots/oidc_button.png)
### Linking accounts
If you've already started using KitchenOwl or created an account first you can link an OIDC account to your existing KitchenOwl account. Just go to *settings* :material-arrow-right: Click on your profile at the top :material-arrow-right: *Linked Accounts* :material-arrow-right: and link your account.
Account links are permanent and can only be removed by deleting the KitchenOwl account. Users that signed in using OIDC are normal users that, after setting a password, can also sing in using their username + password. Deleting a user from your OIDC authority will not delete a user from KitchenOwl.
### Limitations
Currently only Web, Android, iOS, and macOS are supported.
### Apple & Google
These two providers will allow anyone to sing in with an Apple or Google account. They can be configured similarly to custom providers but will show up with a branded sign in with button.
It is not recommended setting up social logins for self-hosted versions as they might not work correctly.
```yaml
back:
environment:
- [...]
- FRONT_URL=<URL> # front_url is requred when using oidc
- APPLE_CLIENT_ID=<ID>
- APPLE_CLIENT_SECRET=<SECRET>
- GOOGLE_CLIENT_ID=<ID>
- GOOGLE_CLIENT_SECRET=<SECRET>
```
22 changes: 22 additions & 0 deletions lib/cubits/auth_cubit.dart
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,28 @@ class AuthCubit extends Cubit<AuthState> {
}
}

Future<void> loginOIDC(
String state,
String code, [
Function(String?)? feedbackCallback,
]) async {
emit(const Loading());
(String?, String?) res =
await ApiService.getInstance().loginOIDC(state, code);
final token = res.$1;
if (token != null && ApiService.getInstance().isAuthenticated()) {
await SecureStorage.getInstance().write(key: 'TOKEN', value: token);
} else if (ApiService.getInstance().isAuthenticated()) {
if (feedbackCallback != null) feedbackCallback(res.$2);
} else {
await updateState();
if (ApiService.getInstance().connectionStatus == Connection.connected &&
feedbackCallback != null) {
feedbackCallback(res.$2);
}
}
}

Future<void> logout() async {
emit(const Loading());
await SecureStorage.getInstance().delete(key: 'TOKEN');
Expand Down
27 changes: 19 additions & 8 deletions lib/cubits/server_info_cubit.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:kitchenowl/enums/oidc_provider.dart';
import 'package:kitchenowl/services/api/api_service.dart';

class ServerInfoCubit extends Cubit<ServerInfoState> {
Expand Down Expand Up @@ -38,23 +39,33 @@ class ConnectedServerInfoState extends ServerInfoState {
final String? privacyPolicyUrl;
final bool openRegistration;
final bool emailMandatory;
final List<OIDCProivder> oidcProvider;

const ConnectedServerInfoState({
required this.version,
required this.minFrontendVersion,
this.privacyPolicyUrl,
this.openRegistration = false,
this.emailMandatory = false,
this.oidcProvider = const [],
});

factory ConnectedServerInfoState.fromJson(Map<String, dynamic> data) =>
ConnectedServerInfoState(
version: data["version"],
minFrontendVersion: data["min_frontend_version"],
privacyPolicyUrl: data["privacy_policy"],
openRegistration: data["open_registration"] ?? false,
emailMandatory: data["email_mandatory"] ?? false,
);
factory ConnectedServerInfoState.fromJson(Map<String, dynamic> data) {
List<OIDCProivder> oidcProvider = const [];
if (data.containsKey('oidc_provider')) {
oidcProvider = List.from(data['oidc_provider']
.map((e) => OIDCProivder.parse(e))
.where((e) => e != null));
}
return ConnectedServerInfoState(
version: data["version"],
minFrontendVersion: data["min_frontend_version"],
privacyPolicyUrl: data["privacy_policy"],
openRegistration: data["open_registration"] ?? false,
emailMandatory: data["email_mandatory"] ?? false,
oidcProvider: oidcProvider,
);
}

@override
List<Object?> get props => [
Expand Down
4 changes: 2 additions & 2 deletions lib/cubits/settings_user_cubit.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ import 'package:kitchenowl/services/api/api_service.dart';

class SettingsUserCubit extends Cubit<SettingsUserState> {
final int? userId;
SettingsUserCubit(this.userId)
: super(const SettingsUserState(null, false, UpdateEnum.unchanged)) {
SettingsUserCubit(this.userId, [User? initialUserData])
: super(SettingsUserState(initialUserData, false, UpdateEnum.unchanged)) {
refresh();
}

Expand Down
84 changes: 84 additions & 0 deletions lib/enums/oidc_provider.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:kitchenowl/cubits/auth_cubit.dart';
import 'package:kitchenowl/helpers/url_launcher.dart';
import 'package:kitchenowl/kitchenowl.dart';
import 'package:kitchenowl/services/api/api_service.dart';
import 'package:sign_in_with_apple/sign_in_with_apple.dart';

enum OIDCProivder {
custom,
google,
apple;

Widget toIcon(BuildContext context) {
return const [
Icon(Icons.turn_slight_left_outlined),
Image(
image: AssetImage('assets/images/google_logo.png'),
height: 32,
),
Icon(Icons.apple_rounded),
][index];
}

String toLocalizedString() {
return const ["OIDC", "Google", "Apple"][index];
}

@override
String toString() {
return name;
}

static OIDCProivder? parse(String str) {
switch (str) {
case 'custom':
return OIDCProivder.custom;
case 'google':
return OIDCProivder.google;
case 'apple':
return OIDCProivder.apple;
default:
return null;
}
}

Future<void> login(BuildContext context) async {
if (this == OIDCProivder.apple) {
final res = await ApiService.getInstance().getLoginOIDCUrl(toString());
if (res.$2 == null || res.$3 == null) return;
try {
final credential = await SignInWithApple.getAppleIDCredential(
scopes: [
AppleIDAuthorizationScopes.email,
AppleIDAuthorizationScopes.fullName,
],
state: res.$2,
nonce: res.$3,
);
return BlocProvider.of<AuthCubit>(context).loginOIDC(
credential.state!,
credential.authorizationCode,
(message) => showSnackbar(
context: context,
content: Text((message?.contains("DONE") ?? false)
? AppLocalizations.of(context)!.done
: AppLocalizations.of(context)!.error),
width: null,
),
);
} catch (_) {
showSnackbar(
context: context,
content: Text(AppLocalizations.of(context)!.error),
width: null,
);
}
} else {
final url =
(await ApiService.getInstance().getLoginOIDCUrl(toString())).$1;
if (url != null) return openUrl(context, url);
}
}
}
20 changes: 20 additions & 0 deletions lib/l10n/app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@
"@accountDelete": {},
"accountDeleteConfirmation": "Are you sure you want to delete your account?",
"@accountDeleteConfirmation": {},
"accountsLinked": "Linked Accounts",
"@accountsLinked": {},
"accountLinkedWithOtherUser": "Account already linked to another user",
"@accountLinkedWithOtherUser": {},
"add": "Add",
"@add": {},
"addCategory": "Add Category",
Expand Down Expand Up @@ -262,6 +266,10 @@
"@larger": {},
"lastUsed": "Last used",
"@lastUsed": {},
"link": "Link",
"@link": {
"description": "As in link social account (e.g. Google)"
},
"list": "List",
"@list": {},
"lltCreate": "Create long-lived token",
Expand Down Expand Up @@ -467,6 +475,12 @@
"@shoppingLists": {},
"shoppingListStyle": "Shopping list style",
"@shoppingListStyle": {},
"signInWith": "Sign in with {provider}",
"@signInWith": {
"placeholders": {
"provider": {}
}
},
"signup": "Sign up",
"@signup": {},
"start": "Start",
Expand Down Expand Up @@ -515,6 +529,10 @@
"@uncategorized": {},
"underConstruction": "Under Construction",
"@underConstruction": {},
"unlink": "Unlink",
"@unlink": {
"description": "As in remove link to social account (e.g. Google)"
},
"unreachableMessage": "Hmmmm… couldn't reach server",
"@unreachableMessage": {},
"unsavedChangesTitle": "You have unsaved changes",
Expand Down Expand Up @@ -547,6 +565,8 @@
"@usernameInvalid": {},
"usernameUnavailable": "The username is unavailable",
"@usernameUnavailable": {},
"userNotSignedIn": "You need to be signed in to link an account",
"@userNotSignedIn": {},
"users": "Users",
"@users": {},
"userSearchHint": "Type a name",
Expand Down
3 changes: 3 additions & 0 deletions lib/main.dart
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl_standalone.dart';
// ignore: depend_on_referenced_packages
import 'package:flutter_web_plugins/url_strategy.dart';
import 'app.dart';

Future main() async {
WidgetsFlutterBinding.ensureInitialized();
usePathUrlStrategy();
if (!kIsWeb) await findSystemLocale(); //BUG in package for web?
runApp(App());
}
Loading

0 comments on commit 049f5cf

Please sign in to comment.