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:
- The default browser opens the provider’s sign-in page.
- The user signs in there.
- The provider sends the browser back to the app, and the app finishes the sign-in in the background.
- 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
| Key | Provider | What you set |
|---|---|---|
google | Client ID | |
microsoft | Microsoft (Entra ID), multi-tenant endpoint | Client ID |
apple | Apple | Client ID |
github | GitHub (OAuth 2.0, not OpenID Connect) | Client ID, and scopes (see below) |
okta | Okta | Client ID and Issuer, your Okta address |
keycloak | Keycloak | Client ID and Issuer, your realm address |
any other key, such as custom | any OpenID Connect provider | Client 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 profileunless 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.
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 abellui-authfolder under the user’s config directory (the one Go’sos.UserConfigDirreturns). - 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-toolcommand) 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.
Gotchas
- Token files are per provider, not per app. Two Bellui apps run by the
same user that both sign in with
googleshare onegoogle.encfile, and each sign-in replaces the other’s token. CancelAuthonly 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.