Going further

Use your own OAuth server

Sign a Frigear mobile or browser app in at any OpenID Connect issuer, such as OAuth Server, frigear.nu, Keycloak, Auth0 or Zitadel.

Your users may already have accounts at an identity server you run, such as Keycloak, Auth0 or Zitadel. A Frigear mobile or browser app can sign them in there, instead of at frigear.nu. This page lists what that issuer must support, and how to set it up for the common ones.

For: app developers whose app uses Auth in client mode, and whoever runs the issuer.

Auth in client mode signs users in as a public OAuth application, with PKCE (Proof Key for Code Exchange). It needs neither OAuth Server nor frigear.nu. Any issuer that meets the checklist below works:

  1. Point authentication.client.issuer at it.
  2. Register the app there as a public application.

The rest of the app is unchanged: the mobile shell, $api and the sign-in page. Why an app in a browser or on a device has no secret is on Public and confidential applications.

The issuer checklist

Check each item at the issuer before you debug the app.

  • Discovery. GET <issuer>/.well-known/openid-configuration answers JSON whose issuer equals the configured issuer exactly, including any trailing path. oauth-authorization-server (RFC 8414) works too.
  • PKCE with S256. The issuer accepts code_challenge_method=S256. When the discovery document has code_challenge_methods_supported, it must list S256. The client refuses the issuer otherwise.
  • A public application with no secret. The application has token_endpoint_auth_method: none. The token and revoke endpoints accept a client_id without a client_secret.
  • Exact redirect URIs. Register each URI exactly, with no wildcards. See Redirect URIs.
  • CORS from the webview. These endpoints answer cross-origin requests (CORS) from capacitor://localhost (iOS), https://localhost (Android) and your dev origin, with the Authorization header allowed: the token, revocation, userinfo and discovery endpoints, and your API. Use exact origins, never *. The authorization endpoint is a navigation and needs no CORS.
  • Refresh tokens. The application is allowed the refresh_token grant and the offline_access scope. The issuer rotates refresh tokens: a used one is replaced, and a replayed one revokes the family.
  • iss in the authorization response. The issuer returns iss in the redirect (RFC 9207) and advertises authorization_response_iss_parameter_supported: true.
  • An ID token. The scopes include openid.
Never put a client secret in the app. It would ship in the bundle. A production build that embeds NUXT_PUBLIC_AUTHENTICATION_CLIENT_CLIENT_SECRET fails.

Discovery. Without a discovery document, set authentication.client.discovery: false. The client then uses the paths of OAuth Server, or the ones you list in authentication.client.endpoints. Listing endpoints alone does not turn discovery off.

Refresh tokens. Add offline_access to authentication.client.scopes. Otherwise the session ends when the access token expires.

iss. The client rejects a response whose iss differs from the issuer. It also rejects a response without iss when the issuer advertises support for it.

ID token. The signed-in user (useUserSession().user) is mapped from the ID token's claims: sub, name, email, picture and role. A sign-in whose response has no valid ID token fails. A userinfo endpoint is needed only with client.userinfo: true, or when your client.mapUser calls userinfo().

Redirect URIs

AppRedirect URIExample
Web<origin>/auth/callbackhttps://app.example.com/auth/callback, and http://localhost:3000/auth/callback for development
Native<appId in lowercase>:/auth/callbackcom.example.app:/auth/callback

A native redirect URI is a custom scheme with one slash and no host. Some providers call it a private-use URI scheme, or a "native" application type. When you change callbackPath or native.redirectUri, register the changed URIs.

OAuth Server

With OAuth Server on the issuer, the app is a public application: PKCE only, and no secret. Define it in config, so its client_id is the same in every environment. The full walkthrough, including confidential applications, is Create an OAuth application.

nuxt.config.ts (the issuer)
export default defineNuxtConfig({
  oauthServer: {
    // Your own API that the app calls with the access token.
    cors: { paths: ['/api/'] },
    clients: [{
      id: 'bills',
      name: 'Bills',
      redirectUris: ['nu.frigear.bills:/auth/callback'],
      // Accepted under `nuxt dev` only.
      devRedirectUris: ['http://localhost:3001/auth/callback'],
      // The webviews' origins; native redirect URIs have none to derive.
      corsOrigins: ['capacitor://localhost', 'https://localhost'],
    }],
  },
})
OptionPurpose
oauthServer.clientsPublic applications with a fixed client_id. NUXT_OAUTH_SERVER_CLIENTS replaces the list with JSON.
oauthServer.cors.originsExact origins that may call the token, revoke, userinfo and discovery endpoints. The origins of configured clients are added.
oauthServer.cors.pathsPath prefixes of your own API that those origins may call with a bearer token.

The app side:

nuxt.config.ts (the app)
export default defineNuxtConfig({
  modules: ['@nuxt/ui', '@frigear-nu/auth'],
  authentication: {
    mode: 'client',
    client: {
      issuer: 'https://id.example.com',
      clientId: 'bills',
      scopes: ['openid', 'profile', 'email', 'offline_access'],
      native: { scheme: 'nu.frigear.bills' },
    },
  },
})
Configured clients and cors.paths need OAuth Server 0.4.0 or later. On an older version, register the application in the admin area as a public application. Add the native redirect URI to it, and list the webview origins in oauthServer.cors.origins. See OAuth Server.

frigear.nu

frigear.nu runs OAuth Server. Use issuer: 'https://frigear.nu'.

  • Registration. Staff of frigear.nu register the application by hand in the admin area, with the client type Public and the redirect URIs above. Ask one of them: there is no self-service registration.
  • CORS. It already allows capacitor://localhost and https://localhost, so the webview needs nothing more.
  • Your services. Services behind the app accept the same access token through OAuth Resource. So one sign-in serves every service.

Keycloak

Setting names vary between versions. The intent is what matters.

  1. Create a client in the realm: Client authentication off (public), Standard flow on and Direct access grants off.
  2. Set Valid redirect URIs to the web and native callback URIs.
  3. Set Web origins to capacitor://localhost, https://localhost and your dev origin. This sets CORS.
  4. Under Advanced, set Proof Key for Code Exchange Code Challenge Method to S256.
  5. Turn on Revoke Refresh Token in the realm's token settings. That turns on refresh token rotation.
  6. Make offline_access a client scope of the client.

The issuer is https://<host>/realms/<realm>.

Auth0

  1. Create an application of type Native for apps in a Capacitor webview, or Single Page Application for a browser only.
  2. Add the callback URIs to Allowed Callback URLs.
  3. Add the origins to Allowed Origins (CORS) and Allowed Web Origins.
  4. Enable Refresh Token Rotation in the application's settings.
  5. When you call an API registered at Auth0, add audience to authentication.client.authorizationParams, and allow offline access on that API.

The issuer is https://<tenant>.auth0.com/, or your custom domain.

Zitadel

  1. Create an application of type Native, with authentication method None and PKCE, or of type User Agent.
  2. Register the redirect URIs. Native apps accept custom-scheme redirects.
  3. Set Refresh Token under grant types.
  4. Add the origins under Additional origins.
  5. To register http://localhost redirects, set the application to Development mode.

The issuer is your instance URL.

What you should see: the app's sign-in page sends you to the issuer, and you come back signed in. The session survives the access token's expiry, because the refresh token renews it.

Troubleshooting

SymptomLikely cause
The browser shows invalid_redirect_uri or redirect_uri_mismatchThe URI is not registered exactly. Compare scheme, case, slashes and the path (appId:/auth/callback, one slash).
config error: issuer mismatchThe discovery document's issuer differs from authentication.client.issuer, for example http against https or another path.
config error: invalid_clientThe application is confidential, or its id is wrong. Register a public application.
Sign-in works in the browser, then the token request fails in the appCORS: the token endpoint does not allow capacitor://localhost or https://localhost.
Signed in, but the session ends when the access token expiresNo offline_access scope, no refresh_token grant, or the provider issues no refresh token to public applications.
Signed out after the first refreshThe provider rotated the refresh token and the response did not carry the new one, or two apps share one refresh token.
The $api calls get 401 and the user is signed outThe API does not accept the token (audience, issuer) even after a refresh.
The $api calls get 401, and the user stays signed inNo token was sent: the request's origin is not client.apiBase, the issuer or one of client.apiOrigins.
The sign-in fails right after the redirect backNo ID token in the token response. Add openid to the scopes, and check that the issuer returns one to this application.
The app does not come back after signing in on a deviceThe native URL scheme is not registered in the iOS or Android project. Run frigear-mobile native and frigear-mobile doctor.
A production build fails with a client secret errorNUXT_PUBLIC_AUTHENTICATION_CLIENT_CLIENT_SECRET is set. Remove it, and use a public application.

Next steps

Copyright © 2026