Flutter×Auth0でPKCEフローOAuth認証を実装する

こんにちは、株式会社Pentagonでエンジニアをしている石渡港です。

目次

この記事の内容

  • PCKE(Proof Key for Code Exchange)認証とは
    フローの詳細はこちら
    PKCE(Proof Key for Code Exchange 通称ピクシー)とは認可コード横取り攻撃の対策として提案された仕様です。
    コードとコードチャレンジ、コード検証を利用して横取りの対策をしています。
    詳しくはこちら
  • この記事どんな記事?
    FlutterとAuth0を利用したPKCEフローOAuth認証を実装できるようになります
  • なぜ書いているの?
    詳しく書いている記事がなかったため

実装方法

import 'dart:convert';
import 'dart:developer';
import 'dart:io';

import 'package:flutter/services.dart';
import 'package:flutter_appauth/flutter_appauth.dart';
import 'package:flutter_flavor/flutter_flavor.dart';
import 'package:http/http.dart' as http;

class Auth0Service {
  /// -----------------------------------
  ///          External Packages
  /// -----------------------------------
  final FlutterAppAuth appAuth = FlutterAppAuth();

  /// -----------------------------------
  ///           Auth0 Variables
  /// -----------------------------------

  String get auth0Domain => 'your domain';
  String get auth0ClientId =>
      'your client id';
  String get auth0RedirectUri => 'your redirect uri';
  String get aut0Issuer => 'https://$auth0Domain';

  /// IdToken parse -> user profile etc...
  Map<String, dynamic> _parseIdToken(String idToken) {
    final parts = idToken.split(r'.');
    assert(parts.length == 3);

    return jsonDecode(
      utf8.decode(
        base64Url.decode(
          base64Url.normalize(
            parts[1],
          ),
        ),
      ),
    ) as Map<String, dynamic>;
  }

 /// get Auth0 user details
  Future<Map<String, dynamic>> _getUserDetails(String accessToken) async {
    final url = 'https://$auth0Domain/userinfo';
    final response = await http.get(
      Uri.parse(url),
      headers: {'Authorization': '$Token Type $accessToken'},
    );

    if (response.statusCode == 200) {
      return jsonDecode(response.body) as Map<String, dynamic>;
    } else {
      throw Exception('Failed to get user details');
    }
  }

  /// Auth0 login
  Future<AuthorizationTokenResponse?> loginAction() async {
    final result = await login();
    return result;
  }

  /// Auth0 get code
  Future<AuthorizationTokenResponse?> login() async {
    try {
      final serviceConfiguration = AuthorizationServiceConfiguration(
          'https://$auth0Domain/authorize', 'https://$auth0Domain/oauth/token');

      final result = await appAuth.authorizeAndExchangeCode(
        AuthorizationTokenRequest(
          auth0ClientId, auth0RedirectUri,
          issuer: 'https://$auth0Domain',
          serviceConfiguration: serviceConfiguration,
          scopes: [
            'openid',
            'profile',
            'email',
            'offline_access',
            'api',
          ],
          additionalParameters: {
            'audience': 'your audience',
          },
          promptValues: [
            'login'
          ], // ignore any existing session; force interactive login prompt
        ),
      );
      /// SecureはSecureStoregeをWrapしたクラス
      if (result.tokenType != null) {
        await Secure.setTokenType(result.tokenType);
      }
      if (result.accessTokenExpirationDateTime != null) {
        await Secure.setAccessTokenExpire(
            result.accessTokenExpirationDateTime?.toLongDateString());
      }
      if (result.accessToken != null) {
        await Secure.setAccessToken(result.accessToken);
      }
      if (result.refreshToken != null) {
        await Secure.setRefreshToken(result.refreshToken);
      }
      if (result.idToken != null) {
        await Secure.setIdToken(result.idToken);
      }
      return result;
    } on Object catch (e) {
      final exception = e as PlatformException;
      if (exception.code != 'authorize_and_exchange_code_failed') {
        rethrow;
      }
    }
  }

  /// リフレッシュするか否か
  Future<bool> doRefresh() async {
    final _isLogin = await isLogin();
    if (!_isLogin) {
      return false;
    }
    final _isExpireAccessToken = await isExpireAccessToken();
    return _isExpireAccessToken;
  }

  /// ログイン中か否か
  Future<bool> isLogin() async {
    final storedRefreshToken = await getStoredRefreshToken();
    if (storedRefreshToken?.isEmpty ?? true) {
      return false;
    }
    return true;
  }

  /// アクセストークンが期限切れか否か
  Future<bool> isExpireAccessToken() async {
    final expireDateTimeString = await Secure.accessTokenExpire;
    if (expireDateTimeString?.isEmpty ?? true) {
      return true;
    }
    final expireDate = DateTime.parse(expireDateTimeString!);
    final toDay = DateTime.now();
    final isAfter = toDay.isAfter(expireDate);
    return isAfter;
  }

  /// リフレッシュトークンの取得
  Future<String?> getStoredRefreshToken() async {
    return Secure.refreshToken;
  }

  /// リフレッシュトークンを利用して更新
  Future<void> refreshToken() async {
    try {
      final storedRefreshToken = await getStoredRefreshToken();
      final response = await appAuth.token(TokenRequest(
        auth0ClientId,
        auth0RedirectUri,
        issuer: aut0Issuer,
        refreshToken: storedRefreshToken,
        grantType: GrantType.refreshToken,
        scopes: [
          'openid',
          'profile',
          'email',
          'offline_access',
          'api',
        ],
      ));

      final idToken = _parseIdToken(response!.idToken!);
      print(idToken.toString());

      final profile = await _getUserDetails(response.accessToken!);
      print(profile.toString());

      await Secure.setAccessToken(response.accessToken);

      if (response.refreshToken != null) {
        await Secure.setRefreshToken(response.refreshToken);
      }
    } on Object catch (e, s) {
      print('error on refresh token: $e - stack: $s');
      await logoutAction();
    }
  }

  /// ログアウト
  Future<void> logoutAction() async {
    await Secure.deleteAll();
  }
}

注意点

  • FlutterAppAuth#authorizeAndExchangeCodeでコード交換まで対応してくれる
  • additionalParameters を指定すること、指定しないと通常のフロー扱いとなり、access_tokenなどが想定外のものが返ってくる
  • scopesにoffline_accessを指定、なおかつAuth0でAllow Offline Access設定を有効にしないとrefresh_tokenが帰ってこないこと
採用情報はこちら
目次