Skip to content
Sign-in with OIDC and OAuth

Sign-in with OIDC and OAuth

The auth package signs a user in through their identity provider, using the browser they already use. It returns the user’s name, email and tokens. The widget.AuthButton wraps the whole flow in a branded button.

The flow is OAuth 2.0 Authorization Code with PKCE (a one-time secret that proves the app that started the sign-in is the one finishing it). It is the flow intended for desktop apps. The package is written for desktop: it opens the system browser and listens on a local port.

Add a sign-in button

signIn := widget.NewAuthButton("google", googleClientID, func(r auth.OIDCResult) {
	// Runs on the UI thread, so it can update widgets directly.
	status.SetText("Signed in as " + r.Name + " (" + r.Email + ")")
})
signIn.OnError = func(err error) {
	status.SetText("Sign-in failed: " + err.Error())
}

NewAuthButton takes a provider key, the client ID your provider gave you, and a function to call once the user is signed in. When the user taps the button:

  1. The default browser opens the provider’s sign-in page.
  2. The user signs in there.
  3. The provider sends the browser back to the app, and the app finishes the sign-in in the background.
  4. Your function runs on the UI thread with an auth.OIDCResult. It can update widgets directly.

If the sign-in fails, OnError runs instead, also on the UI thread. A tap while a sign-in is already in progress is ignored.

The button draws the provider’s logo and brand colors. Set Text or Logo to override them. Scopes is a space-separated list, for example "openid email profile", and Issuer sets the provider address when the key needs one (see below).

Providers

KeyProviderWhat you set
googleGoogleClient ID
microsoftMicrosoft (Entra ID), multi-tenant endpointClient ID
appleAppleClient ID
githubGitHub (OAuth 2.0, not OpenID Connect)Client ID, and scopes (see below)
oktaOktaClient ID and Issuer, your Okta address
keycloakKeycloakClient ID and Issuer, your realm address
any other key, such as customany OpenID Connect providerClient ID and Issuer

The logos, colors and default labels live in auth.Providers. Keycloak has no logo there. An unknown key gets a grey button labelled “Sign in”.

What you register with the provider

Create an app registration (often called a “client”) of the native, desktop or public kind. Then check three things:

  • Redirect URI. The app listens on http://127.0.0.1:<port>/callback, and the operating system picks a free port for each sign-in. The provider must accept a loopback redirect on any port.
  • No client secret. The app sends only the client ID and the PKCE verifier when it exchanges the code for tokens. A registration that requires a client secret will reject the exchange.
  • Scopes. OpenID Connect providers get openid email profile unless you set others.

The client ID is not a secret. It is fine to compile it into the app.

How the sign-in is checked

For every provider, including GitHub, the package sends a random state value with the request and rejects a reply that does not return it.

For every provider except GitHub, the package also:

  • reads the provider’s discovery document from <issuer>/.well-known/openid-configuration, and rejects it if the issuer inside does not match the one you asked for;
  • verifies the ID token’s signature against the provider’s published keys, and checks its issuer, audience (your client ID), expiry and nonce.

The browser shows a short “Authentication Successful” page that tells the user to return to the app. The whole flow times out after five minutes.

GitHub

GitHub uses plain OAuth 2.0, so there is no ID token to verify. After the code exchange the package asks the GitHub API for the user. In the result, Sub is the numeric GitHub user ID, and Name falls back to the login when the user has no display name. Of the three token fields, only AccessToken is set: GitHub sign-ins have no refresh token and no ID token.

Set the scopes for GitHub yourself, for example Scopes: "user:email read:user" on the button. If you leave them empty, the OpenID Connect defaults (openid email profile) are sent to GitHub too. This is a known issue: bellui#335.

Without the button

Call auth.Authenticate when you want your own UI. It blocks until the sign-in finishes, fails or times out, so run it in a goroutine and come back to the UI thread with bellui.Do:

sso := widget.NewButton("Sign in with company SSO", func() {
	go func() { // Authenticate blocks until the browser flow ends.
		r, err := auth.Authenticate(auth.OIDCConfig{
			Provider: "okta",
			Issuer:   "https://example.okta.com",
			ClientID: "0oa-example-client-id",
		})
		bellui.Do(func() { // Back on the UI thread.
			if err != nil {
				status.SetText("Sign-in failed: " + err.Error())
				return
			}
			status.SetText("Signed in as " + r.Email)
		})
	}()
})

auth.OIDCConfig also has Scopes (a slice) and Timeout (five minutes when zero).

Remembering the user

The button saves the refresh token when the provider returns one. You can read it back at the next launch:

// A refresh token saved by an earlier sign-in, if any.
if refresh, email, err := auth.LoadToken("google"); err != nil {
	log.Println("could not read stored token:", err)
} else if refresh != "" {
	status.SetText("Previously signed in as " + email)
}

LoadToken returns empty strings, not an error, when nothing is stored. If you call Authenticate yourself, store the token with SaveToken. To sign out, remove it:

signOut := widget.NewButton("Sign out", func() {
	signIn.CancelAuth()
	if err := auth.ClearToken("google"); err != nil {
		log.Println("could not clear token:", err)
	}
	status.SetText("Not signed in")
})

How the token is stored:

  • It is encrypted with AES-256-GCM in a file named <provider>.enc, in a bellui-auth folder under the user’s config directory (the one Go’s os.UserConfigDir returns).
  • The encryption key comes from a secret kept in the operating system’s secret store: the Keychain on macOS, DPAPI on Windows, and the Secret Service (through the secret-tool command) on Linux.
  • With no secret store available, for example on a headless Linux machine, the secret is a random file in the same folder, readable only by the user.
  • Token files do not work on another machine. A file that cannot be decrypted is deleted, and the user simply signs in again.
The package does not use the refresh token for you. It stores it and gives it back. Exchanging it for a new access token is up to your app.

Gotchas

  • Token files are per provider, not per app. Two Bellui apps run by the same user that both sign in with google share one google.enc file, and each sign-in replaces the other’s token.
  • CancelAuth only stops the button. The button stops waiting and reports nothing. The local listener keeps waiting for the browser until the five-minute timeout.
  • The user must finish in the browser. Nothing in the app window changes while the browser page is open. Show a hint such as “Continue in your browser” if the wait could confuse people.