웹 개발을 할 때 OAuth 로그인을 사용하는 걸 선호한다. OAuth 로그인은 비밀번호 관리가 필요 없기 때문에 보안 부담이 줄어들고, 클릭 몇 번으로 빠르게 서비스를 이용할 수 있어 사용자 편의성이 뛰어나기 때문이다.
그러나 최근 Flutter에서 flutter_inappwebview를 이용해 웹앱을 표시하다 문제에 봉착했다. Google이 보안상의 이유로 WebView에서의 OAuth 로그인을 차단한 것이다.
문제를 해결할 수 있는 간단한 방법은 있었다. 아래와 같이 UserAgent에서 웹뷰임을 나타내는 문자열을 제거하면 OAuth는 정상적으로 작동했다.
InAppWebView(
onWebViewCreated: (controller) async {
webViewController = controller;
String? userAgent = await webViewController.evaluateJavascript(source: 'navigator.userAgent');
String? updatedUserAgent = userAgent;
if (userAgent != null) {
// `; wv` 문자열 제거
// `MyApp` 은 앱에서 실행됨 여부를 확인하기 위함
updatedUserAgent = '${userAgent.replaceAll('; wv', '')}; MyApp';
}
webViewController.setSettings(
settings: InAppWebViewSettings(
userAgent: updatedUserAgent,
),
);
}
),
하지만 이는 Google의 정책을 우회하는 편법이기 때문에 가슴 한 켠의 불편함을 떨칠 수가 없었다.
그래서 해결책을 찾다보니 Custom Tabs를 이용해 해결하는 방법이 있었다.
전체적인 흐름은 다음과 같다.
다음과 같은 상황을 가정하고 시작해보자.
myapp.com
이다.https://myapp.com/oauth/google?toApp=true
https://myapp.com/oauth/google
먼저 flutter_custom_tabs와 딥링크 처리를 위한 app_links를 설치한다.
flutter pub add flutter_custom_tabs app_links
딥링크 처리에 uni_links를 주로 사용하는데, 내 경우 uni_links 사용 시 Namespace not specified 오류가 발생해 app_links로 대체했다. 참고 링크.
그리고 android/app/AndroidManifest.xml
에 Custom Scheme을 등록해준다.
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="myapp" />
<data android:host="myapp.com" />
</intent-filter>
Custom Scheme으로 myapp://
을 사용하고, myapp.com
은 표시할 웹앱의 호스트명을 입력해준다. 이제 myapp://myapp.com
으로 시작하는 모든 URL은 앱을 열 수 있게 되었다.
다음은 WebView에서 로그인 처리 화면을 Custom Tabs에서 열기 위한 처리를 해준다. 내 경우 host가 myapp.com
과 다른 경우 모두 Custom Tabs에서 열리게 다음과 같이 처리했다.
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
InAppWebView(
onWebViewCreated: (controller) async {
webViewController = controller;
String? userAgent = await webViewController.evaluateJavascript(source: 'navigator.userAgent');
String? updatedUserAgent = userAgent;
if (userAgent != null) {
// `MyApp` 은 앱에서 실행됨 여부를 확인하기 위함
updatedUserAgent = '$userAgent; MyApp';
}
webViewController.setSettings(
settings: InAppWebViewSettings(
userAgent: updatedUserAgent,
),
);
}
shouldOverrideUrlLoading: (InAppWebViewController controller, NavigationAction navigationAction) async {
WebUri? webUri = navigationAction.request.url;
if (webUri != null && webUri.host != 'myapp.com') {
// 내 앱의 host와 다른 경우 `_launchURL()`을 이용해 Custom Tabs로 열기
_launchURL(context, webUri);
// WebView 내에서 네비게이션 동작 막기
return NavigationActionPolicy.CANCEL;
} else {
return NavigationActionPolicy.ALLOW;
}
},
)
import 'package:flutter_custom_tabs/flutter_custom_tabs.dart'
as FlutterCustomTabs;
import 'package:flutter_custom_tabs/flutter_custom_tabs.dart';
void _launchURL(BuildContext context, WebUri uri) async {
final theme = Theme.of(context);
try {
await launchUrl(
uri,
prefersDeepLink: true,
customTabsOptions: CustomTabsOptions(
colorSchemes: CustomTabsColorSchemes.defaults(
toolbarColor: theme.colorScheme.surface,
),
// flutter_inappwebview 에도 `CustomTabsShareState` 가 있기 때문에 충돌 피하기 위함
shareState: FlutterCustomTabs.CustomTabsShareState.on,
urlBarHidingEnabled: true,
showTitle: true,
closeButton: CustomTabsCloseButton(
icon: CustomTabsCloseButtonIcons.back,
),
),
safariVCOptions: SafariViewControllerOptions(
preferredBarTintColor: theme.colorScheme.surface,
preferredControlTintColor: theme.colorScheme.onSurface,
barCollapsingEnabled: true,
dismissButtonStyle: SafariViewControllerDismissButtonStyle.close,
),
);
} catch (e) {
debugPrint(e.toString());
}
}
_launchURL()
코드 작성 시 상단 import 구문에 import 'package:flutter_custom_tabs/flutter_custom_tabs.dart' as FlutterCustomTabs;
를 사용한 이유는 flutter_inappwebview 패키지에도 CustomTabsShareState
가 있기 때문이다. 나는 두 코드를 한 파일에 작성했기 때문에 위와 같이 작성했다.
앞서 우리는 구글 OAuth에 두 가지 Redirect URI가 있다고 가정했다. 그리고 UserAgent에 MyApp
이라는 문자열을 추가해 앱의 웹뷰인지 아닌지 판단할 수 있는 방법도 마련해뒀다.
이제 클라이언트에서 웹뷰 여부를 판단해 웹뷰일때는 redirect_uri 파라미터에 https://myapp.com/oauth/google?toApp=true
를, 웹뷰가 아닐 때는 https://myapp.com/oauth/google
를 사용하게 해준다.
웹뷰일 경우 구글 OAuth 클라이언트에서 로그인이 완료된 뒤 https://myapp.com/oauth/google?toApp=true
로 리다이렉트 하게 될 텐데, 이 때 다음과 같이 Custom Scheme을 이용해 앱을 열어준다.
location.href = `myapp://myapp.com/additional-path-or-queries`;
나는 additional-path-or-queries
에 리다이렉트 후의 Pathname과 쿼리, 프래그먼트를 모두 넘겨주었다.
주의할 점은 Redirect URI에서 toApp
파라미터가 없거나 웹뷰일 때는 앱 내 로그인 처리를 진행해줘야 한다.
이제 앱에서 딥링크를 처리하기 위해 WebView를 그리는 위젯에 다음과 같이 작성해준다.
class MyWebView extends StatefulWidget {
const MyWebView ({super.key});
@override
State<StatefulWidget> createState() {
return _MyWebView ();
}
}
class _WebViewScreen extends State<WebViewScreen> {
late InAppWebViewController webViewController;
late AppLinks _appLinks;
StreamSubscription? _listenSubscription;
bool canClose = false;
@override
void initState() {
super.initState();
_appLinks = AppLinks();
_listenSubscription = _appLinks.uriLinkStream.listen((Uri uri) {
String replacedUri = uri.toString().replaceAll('myapp://', 'https://');
webViewController.loadUrl(
urlRequest: URLRequest(url: WebUri(replacedUri)));
});
}
@override
void dispose() {
_listenSubscription?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
// flutter_inappwebview 위젯 생성 코드
}
}
위 코드에서는 딥링크 수신 시 myapp://
이라는 Custom Scheme을 https://
로 변경해준 뒤 WebView 화면을 변경된 URL을 이용해 다시 로딩한다.
이는 Custom Tabs에서 OAuth 처리 결과로 도달했던 Redirect URI를 동일하게 여는 역할을 한다.
써놓고 보니 제법 간단해 보이지만 실제 구현 과정에서는 여러 문제에 봉착했었다.
http
, https
scheme 사용 시 딥링크가 동작하지 않았다.http
, https
scheme은 모바일 브라우저에서 우선권을 가지는건지, https://myapp.com
으로 쓰인 링크를 클릭할 때는 정상적으로 앱이 실행됐지만 location.href
를 이용한 이동은 딥링크가 작동하지 않았다.
http
, https
로 시작하는 것만 가능하다.그래서 Custom Scheme을 등록한 뒤, 이를 구글 OAuth Redirect URI로 설정하려고 했는데 구글은 http
, https
로 시작하는 URI만 설정할 수 있었다.
window.open()
은 확인 팝업을 띄운다.이런저런 문제를 겪은 뒤 현재의 방법을 찾게 됐는데, 처음엔 location.href
대신 window.open()
을 썼더니 모바일 브라우저에서 앱을 열 것인지 확인하는 팝업이 떴고 '확인'을 눌러야만 앱이 열렸다. 이는 UX에 좋지 않다고 판단해 시험삼아 location.href
로 바꿔봤더니 정상적으로 확인 팝업 없이 앱이 열리는 것을 볼 수 있었다.
이런 저런 문제를 겪으며 상당히 오랜 시간이 걸려 문제를 해결했지만, 결국 해결하고 나니 또 이만큼 뿌듯한 일이 없다.
다음에는 Flutter 개발 과정에서 겪었던 또 다른 문제점과 해결법을 써볼까 한다.
#Flutter #GoogleOAuth #WebView #flutter_inappwebview #OAuth2.0 #하이브리드앱 #CustomTabs #DeepLink #AppLinks #SocialLogin #GoogleSignIn #MobileAuth #FlutterDev #앱개발 #CustomScheme #AndroidDev #iOS개발 #UserAgent #리다이렉트 #SSO