From b4b0291540885c3562787c5684cdb22df61f52f6 Mon Sep 17 00:00:00 2001 From: lemoer Date: Sat, 5 Jul 2025 20:08:14 +0200 Subject: [PATCH] Make basic auth flow --- app/lib/authentik_api.dart | 106 +++++++++ app/lib/main.dart | 433 +++++++++++++++++++++++-------------- 2 files changed, 374 insertions(+), 165 deletions(-) create mode 100644 app/lib/authentik_api.dart diff --git a/app/lib/authentik_api.dart b/app/lib/authentik_api.dart new file mode 100644 index 0000000..1abe739 --- /dev/null +++ b/app/lib/authentik_api.dart @@ -0,0 +1,106 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:oauth2/oauth2.dart' as oauth2; +import 'package:url_launcher/url_launcher.dart'; + +Future getSessionCookie(oauth2.Client client) async { + final response0 = await client.get( + Uri.parse( + 'https://auth.leinelab.org/api/v3/flows/instances/default-user-settings-flow/execute/', + ), + ); + + var sessionCookieHeader = response0.headers['set-cookie']; + if (sessionCookieHeader == null) { + throw Exception('No session cookie found in response headers.'); + } + + String? sessionCookie; + int index = sessionCookieHeader.indexOf(';'); + sessionCookie = (index == -1) + ? sessionCookieHeader + : sessionCookieHeader.substring(0, index); + + print("Session cookie: $sessionCookie"); + + return sessionCookie; +} + +Future getUserSettings( + oauth2.Client client, + String sessionCookie, +) async { + final response = await client.get( + Uri.parse( + 'https://auth.leinelab.org/api/v3/flows/executor/default-user-settings-flow/?query=', + ), + headers: {'Cookie': sessionCookie}, + ); + + final flowJson = jsonDecode(response.body); + + if (flowJson['fields'] == null) { + throw Exception("Expected 'fields' in response, but got: ${response.body}"); + } + + final fields = flowJson['fields'] as List; + var userSettingsObj = {}; + for (var field in fields) { + if (field['field_key'] == null || field['initial_value'] == null) { + throw Exception( + "Expected 'field_key' and 'initial_value' in field, but got: $field", + ); + } + + userSettingsObj[field['field_key']] = field['initial_value']; + } + + return userSettingsObj; +} + +Future setUserSettings( + oauth2.Client client, + String sessionCookie, + Object data, +) async { + final body = jsonEncode(data); + + final response = await client.post( + Uri.parse( + 'https://auth.leinelab.org/api/v3/flows/executor/default-user-settings-flow/?query=', + ), + body: body, + headers: {'Content-Type': 'application/json', 'Cookie': sessionCookie}, + ); + + // Authentik expects a redirect after the POST request and only writes + // the data to the database after fetching the redirect location. + if (response.statusCode != 302) { + throw Exception( + "Expected a redirect (302) response, but got ${response.statusCode}", + ); + } + + final newLocation = response.headers['location']; + if (newLocation == null) { + throw Exception("No redirect location found in response headers."); + } + + final responseFinal = await client.get( + Uri.parse('https://auth.leinelab.org/' + newLocation), + headers: {'Cookie': sessionCookie}, + ); + + if (responseFinal.statusCode == 200) { + print("User data updated successfully."); + print("responseFinal body:"); + print(responseFinal.body); + responseFinal.headers.toString().split('\n').forEach(print); + } else { + print("Error updating user data: ${responseFinal.statusCode}"); + print("responseFinal body:"); + print(responseFinal.body); + print(responseFinal.headers.toString()); + } +} diff --git a/app/lib/main.dart b/app/lib/main.dart index e13064c..744bc68 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -16,76 +16,22 @@ import 'package:url_launcher/url_launcher.dart'; import 'package:provider/provider.dart'; +import 'authentik_api.dart' as authentik; + void main() { runApp( - ChangeNotifierProvider( - create: (context) => SSHKeyList(), + MultiProvider( + providers: [ + ChangeNotifierProvider(create: (context) => SSHKeyList()), + ChangeNotifierProvider( + create: (context) => AuthentikUserSettingsChangeDialogState(), + ), + ], child: const MyApp(), ), ); } -Future getOAuth2Client() async { - // This is a placeholder for OAuth2 client initialization. - // Replace with your actual OAuth2 client setup. - - final authorizationEndpoint = Uri.parse( - 'https://auth.leinelab.org/application/o/authorize/', - ); - - final tokenEndpoint = Uri.parse( - 'https://auth.leinelab.org/application/o/token/', - ); - - final identifier = 'UwSMm8gTwBTUURSaxp5uPpuwX1OkGO4FRHeO9v3i'; - final secret = null; // = 'my client secret'; - - final redirectUrl = Uri.parse('http://localhost:30165/'); - - final credentialsFile = File('~/.myapp/credentials.json'); - - //.... - - var exists = await credentialsFile.exists(); - - if (exists) { - var credentials = oauth2.Credentials.fromJson( - await credentialsFile.readAsString(), - ); - return oauth2.Client(credentials, identifier: identifier, secret: secret); - } - - var grant = oauth2.AuthorizationCodeGrant( - identifier, - authorizationEndpoint, - tokenEndpoint, - secret: secret, - ); - - var authorizationUrl = grant.getAuthorizationUrl( - redirectUrl, - scopes: ["profile", "email", "goauthentik.io/api", "openid"], - ); - - // TODO: clicking the button twice might try to bind the server twice - var server = await HttpServer.bind("127.0.0.1", 30165); - - await launchUrl(authorizationUrl); - - var queryParameters; - - await server.forEach((HttpRequest request) { - request.response.write( - 'Success! You can close this window now and go back to the app.', - ); - queryParameters = request.uri.queryParameters; - request.response.close(); - server.close(); - }); - - return await grant.handleAuthorizationResponse(queryParameters); -} - void makeAlert(BuildContext context, String title, String message) { showDialog( context: context, @@ -155,106 +101,6 @@ class _MyHomePageState extends State { String _output = ''; String key = ''; - Future doOAuth() async { - final client = await getOAuth2Client(); - - // TODO: Handle errors better - try { - final jsonMe = await client.read( - Uri.parse('https://auth.leinelab.org/api/v3/core/users/me/'), - ); - - final me = jsonDecode(jsonMe); - - final user = me['user']; - final transformed = { - "username": user['username'], - "name": user['name'], - "email": user['email'], - "attributes.settings.locale": user['settings']['locale'], - "attributes.sshPublicKeys": - "foooooooobar :O :O!", // fix oder aus anderer Quelle - "component": "ak-stage-prompt", - }; - - print(user); - print(transformed); - - final push = jsonEncode(transformed); - - final response0 = await client.get( - Uri.parse( - 'https://auth.leinelab.org/api/v3/flows/instances/default-user-settings-flow/execute/', - ), - ); - - var sessionCookieHeader = response0.headers['set-cookie']; - if (sessionCookieHeader == null) { - throw Exception('No session cookie found in response headers.'); - } - - String? sessionCookie; - int index = sessionCookieHeader.indexOf(';'); - sessionCookie = (index == -1) - ? sessionCookieHeader - : sessionCookieHeader.substring(0, index); - - final responsea = await client.get( - Uri.parse( - 'https://auth.leinelab.org/api/v3/flows/executor/default-user-settings-flow/?query=', - ), - headers: {'Cookie': sessionCookie}, - ); - - print("Response A status code: ${responsea.statusCode}"); - print("Response body:"); - print(responsea.body); - - print("Session cookie: $sessionCookie"); - - final response = await client.post( - Uri.parse( - 'https://auth.leinelab.org/api/v3/flows/executor/default-user-settings-flow/?query=', - ), - body: push, - headers: {'Content-Type': 'application/json', 'Cookie': sessionCookie}, - ); - - // Authentik expects a redirect after the POST request and only writes - // the data to the database after fetching the redirect location. - if (response.statusCode != 302) { - throw Exception( - "Expected a redirect (302) response, but got ${response.statusCode}", - ); - } - - final newLocation = response.headers['location']; - if (newLocation == null) { - throw Exception("No redirect location found in response headers."); - } - - final responseFinal = await client.get( - Uri.parse('https://auth.leinelab.org/' + newLocation), - headers: {'Cookie': sessionCookie}, - ); - - if (responseFinal.statusCode == 200) { - print("User data updated successfully."); - print("responseFinal body:"); - print(responseFinal.body); - responseFinal.headers.toString().split('\n').forEach(print); - } else { - print("Error updating user data: ${responseFinal.statusCode}"); - print("responseFinal body:"); - print(responseFinal.body); - print(responseFinal.headers.toString()); - } - } catch (e) { - print(e.toString()); - } - ; - } - Future doSSH() async { SSHSocket? socket; @@ -336,11 +182,19 @@ class _MyHomePageState extends State { ? 'Output: $_output' : 'Press the button to run a command.'; + final authentikApiState = context + .watch(); + + //final userSettingsDialog = + final bodyComponentMain = Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - TextButton(onPressed: doOAuth, child: Text("Oauth2 Login")), + TextButton( + onPressed: authentikApiState.start, + child: Text("Oauth2 Login"), + ), Text('Current output:'), Consumer( builder: (BuildContext context, SSHKeyList sshKeyList, Widget? child) { @@ -393,7 +247,7 @@ class _MyHomePageState extends State { throw Exception('Unknown navIndex: $navIndex'); } - return Scaffold( + final mainPage = Scaffold( appBar: AppBar(backgroundColor: Colors.amber, title: Text(widget.title)), body: bodyComponent, floatingActionButton: actionButton, @@ -430,9 +284,258 @@ class _MyHomePageState extends State { ), ), ); + + if (authentikApiState.isClosed()) { + return mainPage; + } + + var title; + var children = []; + var actions = [ + TextButton(onPressed: authentikApiState.exit, child: Text("Cancel")), + ]; + + switch (authentikApiState.status) { + case AuthentikUserSettingsChangeDialogStatus.closed: + return mainPage; + case AuthentikUserSettingsChangeDialogStatus.waitingForOAuth: + title = Text("Waiting for OAuth"); + children = [Text("Please complete the OAuth flow in your browser.")]; + case AuthentikUserSettingsChangeDialogStatus.loadingUserSettings: + title = Text("Loading User Settings"); + children = [Text("Loading...")]; + case AuthentikUserSettingsChangeDialogStatus.userSettingsObtained: + title = Text("User Settings Obtained"); + children = [ + Text("You can now edit your user settings."), + ElevatedButton( + onPressed: () async { + await authentikApiState.save(); + }, + child: Text("Save User Settings"), + ), + ]; + actions.add( + TextButton( + onPressed: () { + // Close the dialog and return to the main page + authentikApiState.save(); + }, + child: Text("Save and Close"), + ), + ); + case AuthentikUserSettingsChangeDialogStatus.savingUserSettings: + title = Text("Saving User Settings"); + children = [Text("Saving...")]; + } + + final dialog = AlertDialog( + title: title, + content: Column(children: children), + actions: actions, + ); + + return Stack( + children: [ + mainPage, + ModalBarrier(dismissible: false, color: Colors.black54), + dialog, + ], + ); } } +enum AuthentikUserSettingsChangeDialogStatus { + closed, + waitingForOAuth, + loadingUserSettings, + userSettingsObtained, + savingUserSettings, +} + +class AuthentikUserSettingsChangeDialogState extends ChangeNotifier { + AuthentikUserSettingsChangeDialogStatus _status = + AuthentikUserSettingsChangeDialogStatus.closed; + + oauth2.Client? oauthClient; + String? sessionCookie; + Object? userSettings; + HttpServer? server; + + AuthentikUserSettingsChangeDialogStatus get status => _status; + + Future getOAuth2Client() async { + // This is a placeholder for OAuth2 client initialization. + // Replace with your actual OAuth2 client setup. + + final authorizationEndpoint = Uri.parse( + 'https://auth.leinelab.org/application/o/authorize/', + ); + + final tokenEndpoint = Uri.parse( + 'https://auth.leinelab.org/application/o/token/', + ); + + final identifier = 'UwSMm8gTwBTUURSaxp5uPpuwX1OkGO4FRHeO9v3i'; + final secret = null; // = 'my client secret'; + + final redirectUrl = Uri.parse('http://localhost:30165/'); + + // final credentialsFile = File('~/.myapp/credentials.json'); + + // //.... + + // var exists = await credentialsFile.exists(); + + // if (exists) { + // var credentials = oauth2.Credentials.fromJson( + // await credentialsFile.readAsString(), + // ); + // return oauth2.Client(credentials, identifier: identifier, secret: secret); + // } + + var grant = oauth2.AuthorizationCodeGrant( + identifier, + authorizationEndpoint, + tokenEndpoint, + secret: secret, + ); + + var authorizationUrl = grant.getAuthorizationUrl( + redirectUrl, + scopes: ["profile", "email", "goauthentik.io/api", "openid"], + ); + + // TODO: clicking the button twice might try to bind the server twice + server = await HttpServer.bind("127.0.0.1", 30165); + + await launchUrl(authorizationUrl); + + var queryParameters; + + if (server == null) { + // exit() might have been called before we arrived here. + return null; + } + + await server!.forEach((HttpRequest request) { + request.response.write( + 'Success! You can close this window now and go back to the app.', + ); + queryParameters = request.uri.queryParameters; + request.response.close(); + server!.close(); + }); + + return await grant.handleAuthorizationResponse(queryParameters); + } + + Future start() async { + // Reset the state to be sure + oauthClient = null; + sessionCookie = null; + userSettings = null; + + _status = AuthentikUserSettingsChangeDialogStatus.waitingForOAuth; + notifyListeners(); + + oauthClient = await getOAuth2Client(); + if (isClosed()) { + // If the dialog was closed before we got the client, exit + return; + } + + sessionCookie = await authentik.getSessionCookie(oauthClient!); + if (isClosed()) { + // If the dialog was closed before we got the session cookie, exit + return; + } + + _status = AuthentikUserSettingsChangeDialogStatus.loadingUserSettings; + notifyListeners(); + + userSettings = await authentik.getUserSettings( + oauthClient!, + sessionCookie!, + ); + + if (isClosed()) { + // If the dialog was closed before we got the user settings, exit + return; + } + + _status = AuthentikUserSettingsChangeDialogStatus.userSettingsObtained; + notifyListeners(); + } + + void exit() { + _status = AuthentikUserSettingsChangeDialogStatus.closed; + oauthClient = null; + sessionCookie = null; + userSettings = null; + if (server != null) { + server!.close(); + server = null; + } + notifyListeners(); + } + + bool isClosed() { + return _status == AuthentikUserSettingsChangeDialogStatus.closed; + } + + Future save() async { + if (isClosed()) { + return; + } + + if (_status != + AuthentikUserSettingsChangeDialogStatus.userSettingsObtained) { + throw Exception("Cannot save, not started or no settings obtained."); + } + + _status = AuthentikUserSettingsChangeDialogStatus.savingUserSettings; + notifyListeners(); + + await authentik.setUserSettings( + oauthClient!, + sessionCookie!, + userSettings!, + ); + exit(); + } + + // Future doOAuth1() async { + // final client = await getOAuth2Client(); + + // // TODO: Handle errors better + // try { + // final jsonMe = await client.read( + // Uri.parse('https://auth.leinelab.org/api/v3/core/users/me/'), + // ); + + // final me = jsonDecode(jsonMe); + + // final user = me['user']; + // final transformed = { + // "username": user['username'], + // "name": user['name'], + // "email": user['email'], + // "attributes.settings.locale": user['settings']['locale'], + // "attributes.sshPublicKeys": + // "foooooooobar :O :O!", // fix oder aus anderer Quelle + // "component": "ak-stage-prompt", + // }; + + // print(user); + // print(transformed); + // } catch (e) { + // print(e.toString()); + // } + // ; + // } +} + class SSHKeyList extends ChangeNotifier { final List _allKeys = []; final List _keysToKeep = [];