# Vue.js Google Login (Native Sign-In)

Your Vue app ships to iOS and Android through Despia, but a Google sign-in button that works in the browser fails inside the native shell. Google blocks its OAuth screen in an embedded WebView, and the App Store expects a real browser session. Despia gives you the native `oauth://` bridge, which opens the secure system browser, ASWebAuthenticationSession on iOS and Chrome Custom Tabs on Android, and returns the tokens to your Vue app. One flow covers both platforms, with a normal redirect on web.

## Why the web Google button fails in a mobile app

Google refuses its consent screen inside an embedded WebView, the `disallowed_useragent` error, because an embedded view could read the user's credentials. Apple's guidelines agree: OAuth has to run in a browser the app cannot inspect. So the plain web button either errors or is rejected at review. The fix is to leave the WebView for the sign-in. Despia's `oauth://` bridge opens the system browser, the user authenticates, and Despia closes it and returns the tokens. Unlike Apple Sign In, Google uses the same bridge on iOS and Android, so there is no platform branch in your code.

## How the flow works

1.  The user taps Sign in with Google.
    
2.  In Despia, your backend builds a Google OAuth URL that redirects to a small file, `native-callback.html`, and returns it.
    
3.  `despia('oauth://?url=...')` opens that URL in the secure browser.
    
4.  The user signs in. Tokens arrive at `native-callback.html`.
    
5.  That file fires `myapp://oauth/auth?access_token=...`. The `oauth/` prefix tells Despia to close the browser and route the WebView to `/auth`.
    
6.  Your `/auth` route reads the token and creates the session.
    

On web the button runs your provider's normal redirect and lands on the same `/auth`.

## Wire the sign-in button

Before any code, register your deeplink scheme in the Despia dashboard under Publish > Deeplink, that is the value `myapp` stands for below. Then install the SDK with `npm install despia-native` and branch in the click handler:

```javascript
import despia from 'despia-native'

const isDespia = typeof navigator !== 'undefined' &&
  navigator.userAgent.toLowerCase().includes('despia')

async function signInWithGoogle() {
  if (isDespia) {
    // get the OAuth URL from your backend and open it in the secure browser
    const { url } = await fetch('/api/auth/google-url', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ deeplink_scheme: 'myapp' }), // Despia > Publish > Deeplink
    }).then(r => r.json())
    despia(`oauth://?url=${encodeURIComponent(url)}`)
  } else {
    // web: normal redirect, supabase-js installs the session itself on return
    await supabase.auth.signInWithOAuth({ provider: 'google', options: { redirectTo: location.origin + '/auth' } })
  }
}
```

Replace `myapp` with your scheme from Despia > Publish > Deeplink, and swap the web call for your provider if you are not on Supabase.

## Generate the OAuth URL on the backend

Your endpoint builds the URL with `native-callback.html` as the redirect. On Supabase the implicit redirect returns the tokens in the URL hash:

```javascript
// server-side handler, builds the Google OAuth URL
const { deeplink_scheme } = await req.json()
const redirectUrl = `https://yourapp.com/native-callback.html?deeplink_scheme=${encodeURIComponent(deeplink_scheme)}`

const url = `${process.env.SUPABASE_URL}/auth/v1/authorize?` + new URLSearchParams({
  provider: 'google',
  redirect_to: redirectUrl,
  scopes: 'openid email profile', // optional, Supabase requests these by default
  flow_type: 'implicit', // implicit is the default without a code_challenge, kept to document intent
})

return Response.json({ url })
```

Why implicit in this example? Some Vue apps, especially Supabase-style client-session apps, need the OAuth result to land in the browser context that will call `setSession()`. In that setup, the secure browser receives the hash tokens, `native-callback.html` forwards them through Despia's `oauth://` bridge, and `/auth` installs the session in the app. The deeper reason the handoff exists: the OAuth screen runs in a separate secure-browser context, ASWebAuthenticationSession on iOS and Chrome Custom Tabs on Android, whose cookies and storage cannot cross into the WebView by design. A PKCE verifier stored where the flow starts can never meet the code where the session must be installed, so passing the tokens through the deeplink is the bridge between the two worlds. And because the hash carries Supabase's own access and refresh token pair, not a raw Google grant, `setSession()` yields a full session that supabase-js keeps refreshing, users are not signed out when the access token expires.

If you own the backend, or your provider supports a clean PKCE exchange in the app, prefer authorization-code with PKCE instead of this client-token handoff. Supabase supports that with `exchangeCodeForSession()`, and custom backends should generally use code plus PKCE rather than passing long-lived tokens through the URL. The Despia side is identical either way: the same `oauth://` open, the same callback file, the same `oauth/` deeplink home.

## Add public/native-callback.html

This small page runs inside the secure browser. It reads the tokens from the URL hash and fires the deeplink that closes the browser and passes them to your app. Keep it as a plain HTML file in `public/`, not a Vue route, so nothing in the router touches the hash before you read it.

```html
<!-- public/native-callback.html -->
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8" /><title>Completing sign in...</title></head>
<body>
<p>Completing sign in...</p>
<script>
  (function () {
    var params = new URLSearchParams(location.search)
    var scheme = params.get('deeplink_scheme')
    if (!scheme) return

    var hash = new URLSearchParams(location.hash.slice(1))
    var accessToken = hash.get('access_token')
    var refreshToken = hash.get('refresh_token') || ''
    var error = hash.get('error') || params.get('error')

    // oauth/ tells Despia to close the browser and navigate the WebView to /auth
    location.href = accessToken
      ? scheme + '://oauth/auth?access_token=' + encodeURIComponent(accessToken) + '&refresh_token=' + encodeURIComponent(refreshToken)
      : scheme + '://oauth/auth?error=' + encodeURIComponent(error || 'no_access_token')
  })()
</script>
</body>
</html>
```

The `oauth/` prefix is not optional. `myapp://oauth/auth` closes the session and opens `/auth`. `myapp://auth` without it does nothing, and the user is stuck in the browser.

## Read the token on /auth

Both flows end here: the native deeplink puts the token in the query string, the web redirect puts it in the hash. Watch the route query with `immediate: true` so a deeplink into an already-open page still fires, and read the hash as well:

```vue
<script>
export default {
  methods: {
    check() {
      const q = new URLSearchParams(window.location.search)
      const hash = new URLSearchParams(window.location.hash.slice(1))
      const accessToken = q.get('access_token') || hash.get('access_token')
      const refreshToken = q.get('refresh_token') || hash.get('refresh_token') || ''
      if (!accessToken) return

      // setSession is your provider's call, e.g. supabase.auth.setSession()
      setSession({ access_token: accessToken, refresh_token: refreshToken })
        .then(() => this.$router.push('/'))
    }
  },
  watch: {
    // catches query changes when /auth is already mounted
    '$route.query': { immediate: true, handler() { this.check() } }
  },
  mounted() {
    // the watcher misses hash-only changes, this catches them
    window.addEventListener('hashchange', this.check)
  },
  unmounted() {
    window.removeEventListener('hashchange', this.check)
  }
}
</script>
```

A plain `mounted()` hook would run once with empty params and miss a deeplink that lands while `/auth` is already open. The `$route.query` watcher catches query changes, and the `hashchange` listener catches hash-only ones the watcher never sees.

## Set up Google in the Cloud Console

1.  In console.cloud.google.com, open APIs and Services, then Credentials, and create an OAuth 2.0 Client ID of type Web application.
    
2.  Add your app domain under Authorized JavaScript origins.
    
3.  Under Authorized redirect URIs, add your provider's callback. For Supabase that is `https://YOUR-PROJECT.supabase.co/auth/v1/callback`; for a custom backend, your backend callback URL.
    
4.  Copy the Client ID and Client Secret into your provider's Google settings.
    
5.  Set your deeplink scheme from Despia > Publish > Deeplink and replace `myapp` in the code.
    

## The four things that cause almost every failure

The `oauth/` prefix must be in the deeplink, or Despia never closes the browser. `native-callback.html` must be a real file in `public/`, not a Vue route, or the hash gets stripped. `/auth` must read tokens from both the query string and the hash, since native and web deliver them differently. And `/auth` must re-run on a URL change, which is why the `$route.query` watcher runs with `immediate: true` and a `hashchange` listener backs it up for hash-only changes.

## Ship it with native sign-in built in

Take your Vue app to iOS and Android with Google sign-in built for review, from the codebase you already have. Code signing and submission run from the browser, no Mac and no CLI.

[See the setup docs at setup.despia.com](https://setup.despia.com/native-features/oauth/google)
