7

How do you implement Google OAuth2 or Microsoft (Azure) OAuth2 login on Flutter Desktop?

Neal Soni
  • 556
  • 1
  • 5
  • 13

2 Answers2

19

Answering my own question. The overarching process to get the OAuth2 result is:

  1. You have to have the desktop app host a local server and have the OAuth services redirect to http://localhost:#####/ that the dart app is listening to.
  2. Launch the URL to start the OAuth2 flow in the browser using oauth2
  3. On first return to the server, process the OAuth2 response using oauth2

Setup the OAuth flows you want to support. This is what I used:

  1. Go to google admin or azure dashboard,
  2. create a new app + add in Authorized redirect URIs the localhost url: http://localhost/
  3. Copy the generated clientId and clientSecret into the configuration below:
enum LoginProvider { google, azure }

extension LoginProviderExtension on LoginProvider {
  String get key {
    switch (this) {
      case LoginProvider.google:
        return 'google';
      case LoginProvider.azure:
        return 'azure';
    }
  }

  String get authorizationEndpoint {
    switch (this) {
      case LoginProvider.google:
        return "https://accounts.google.com/o/oauth2/v2/auth";
      case LoginProvider.azure:
        return "https://login.microsoftonline.com/common/oauth2/v2.0/authorize";
    }
  }

  String get tokenEndpoint {
    switch (this) {
      case LoginProvider.google:
        return "https://oauth2.googleapis.com/token";
      case LoginProvider.azure:
        return "https://login.microsoftonline.com/common/oauth2/v2.0/token";
    }
  }

  String get clientId {
    switch (this) {
      case LoginProvider.google:
        return "GOOGLE_CLIENT_ID";
      case LoginProvider.azure:
        return "AZURE_CLIENT_ID";
    }
  }

  String? get clientSecret {
    switch (this) {
      case LoginProvider.google:
        return "GOOGLE_SECRET"; // if applicable
      case LoginProvider.azure:
        return "AZURE_SECRET"; // if applicable
    }
  }

  List<String> get scopes {
    return ['openid', 'email']; // OAuth Scopes
  }
}

Setup the OAuth Manager to create listen for the oauth2 redirect

import 'dart:async';
import 'dart:io';

import 'package:http/http.dart' as http;
import 'package:oauth2/oauth2.dart' as oauth2;
import 'package:url_launcher/url_launcher.dart';
import 'package:window_to_front/window_to_front.dart';

class DesktopLoginManager {
  HttpServer? redirectServer;
  oauth2.Client? client;

  // Launch the URL in the browser using url_launcher
  Future<void> redirect(Uri authorizationUrl) async {
    var url = authorizationUrl.toString();
    if (await canLaunch(url)) {
      await launch(url);
    } else {
      throw Exception('Could not launch $url');
    }
  }

  Future<Map<String, String>> listen() async {
    var request = await redirectServer!.first;
    var params = request.uri.queryParameters;
    await WindowToFront.activate(); // Using window_to_front package to bring the window to the front after successful login.  
    request.response.statusCode = 200;
    request.response.headers.set('content-type', 'text/plain');
    request.response.writeln('Authenticated! You can close this tab.');
    await request.response.close();
    await redirectServer!.close();
    redirectServer = null;
    return params;
  }
}

class DesktopOAuthManager extends DesktopLoginManager {
  final LoginProvider loginProvider;

  DesktopOAuthManager({
    required this.loginProvider,
  }) : super();

  void login() async {
    await redirectServer?.close();
    // Bind to an ephemeral port on localhost
    redirectServer = await HttpServer.bind('localhost', 0);
    final redirectURL = 'http://localhost:${redirectServer!.port}/auth';
    var authenticatedHttpClient =
        await _getOAuth2Client(Uri.parse(redirectURL));
    print("CREDENTIALS ${authenticatedHttpClient.credentials}");
    /// HANDLE SUCCESSFULL LOGIN RESPONSE HERE
    return;
  }

  Future<oauth2.Client> _getOAuth2Client(Uri redirectUrl) async {
    var grant = oauth2.AuthorizationCodeGrant(
      loginProvider.clientId,
      Uri.parse(loginProvider.authorizationEndpoint),
      Uri.parse(loginProvider.tokenEndpoint),
      httpClient: _JsonAcceptingHttpClient(),
      secret: loginProvider.clientSecret,
    );
    var authorizationUrl =
        grant.getAuthorizationUrl(redirectUrl, scopes: loginProvider.scopes);

    await redirect(authorizationUrl);
    var responseQueryParameters = await listen();
    var client =
        await grant.handleAuthorizationResponse(responseQueryParameters);
    return client;
  }
}

class _JsonAcceptingHttpClient extends http.BaseClient {
  final _httpClient = http.Client();
  @override
  Future<http.StreamedResponse> send(http.BaseRequest request) {
    request.headers['Accept'] = 'application/json';
    return _httpClient.send(request);
  }
}

Begin the login flow using

GOOGLE:

if (Platform.isMacOS || Platform.isWindows || Platform.isLinux) {
   final provider = DesktopOAuthManager(loginProvider: LoginProvider.google);
   provider.login();
}

AZURE:

if (Platform.isMacOS || Platform.isWindows || Platform.isLinux) {
   final provider = DesktopOAuthManager(loginProvider: LoginProvider. azure);
   provider.login();
}

You're done!

Neal Soni
  • 556
  • 1
  • 5
  • 13
  • 1
    Ah! Run your own http server to listen for the response! I don't know why I didn't think of that.. Thanks! – Vance Palacio Apr 01 '22 at 16:25
  • 4
    Fantastic! Adding my hurdles to save the next person: On MacOS you need to add an com.apple.security.network.client entitlement as explained here: https://stackoverflow.com/questions/65458903/socketexception-connection-failed-os-error-operation-not-permitted-errno-1 And if you get a redirect_uri_mismatch, make sure to add http://localhost/auth to your redirect URI's (no port!). Many more things to try here: https://stackoverflow.com/questions/11485271/google-oauth-2-authorization-error-redirect-uri-mismatch – Oded Ben Dov Jul 06 '22 at 12:45
  • That is to say `http://localhost/auth` (couldn't edit my previous comment) – Oded Ben Dov Jul 06 '22 at 12:52
  • 1
    thanks @OdedBenDov for that addition! I only tested on windows when originally writing this. Glad it still works a year later. – Neal Soni Jul 07 '22 at 13:52
  • 1
    This was extremely helpful, thank you! I got it working in a macOS app that uses the Dropbox API. – Clifton Labrum Jul 23 '22 at 19:20
  • It seems that Apple support neither localhost nor http (without s). – Dmitry Sikorsky Nov 01 '22 at 09:03
  • Anyone know how to create a firebase instance from the credentials returned? Currently, I do not think this works on windows due to platform support – MarvinKweyu Aug 17 '23 at 12:45
1

To add on @Neal's great answer,

If you want his code to return a refresh token as well, add this:

var authorizationUrl = 
    grant.getAuthorizationUrl(redirectUrl, scopes: loginProvider.scopes);

// ADD THIS:
authorizationUrl = authorizationUrl.replace(queryParameters: {
    ...authorizationUrl.queryParameters,
    "access_type": "offline",  
});
Eric Aya
  • 69,473
  • 35
  • 181
  • 253
Oded Ben Dov
  • 9,936
  • 6
  • 38
  • 53