Go
Add IdRa sign-in to a Go net/http server with go-oidc and x/oauth2.
A net/http server using the authorization code flow with PKCE, go-oidc and golang.org/x/oauth2. Go 1.24 or later.
Register the app
Use one app for local development and production. Open your organization's admin console at https://<your-org>.idra.team/admin (or /admin on your own IdRa). Go to Applications → Create App Integration, choose OIDC - OpenID Connect, and set:
- Application type: Web Application
- App integration name: any name
- Sign-in redirect URIs:
http://localhost:8080/callback
Leave Assign this app to me so I can test it on, then select Create. Copy the client secret, shown once, and select I've saved it.
The app's Sign On tab opens:
- Under Connect your app, copy the Issuer and Client ID. The
.envsnippet there has the same values, namedOIDC_*; this quickstart names themIDRA_*. - Add
http://localhost:8080/to Sign-out redirect URIs and select Save.
To let other people sign in, assign them or their groups on Assignments.
Local redirects and production
http redirects work on localhost, 127.0.0.1 or [::1], with a port, and match exactly. PKCE stays required while the app has one. For production, add your https URIs (such as https://app.example.com/callback and https://app.example.com/) to the same app, then remove the local ones.
Configure
IDRA_ISSUER=https://<your-org>.idra.team/api/auth
IDRA_CLIENT_ID=<Client ID>
IDRA_CLIENT_SECRET=<client secret>
APP_URL=http://localhost:8080
SESSION_SECRET=<output of: openssl rand -hex 32>Install
mkdir idra-go && cd idra-go
go mod init idra-go-quickstart
go get github.com/coreos/go-oidc/v3/oidc golang.org/x/oauth2Code
package main
import (
"context"
"crypto/rand"
"encoding/json"
"fmt"
"html"
"log"
"net/http"
"net/url"
"os"
"sync"
"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
)
type session struct {
Verifier, State, Nonce string
User map[string]any
IDToken string
}
// ponytail: sessions live in memory, so they end on restart and don't span replicas. Use a shared store in production.
var (
sessions = map[string]*session{}
mu sync.Mutex
)
// sessionFor returns the browser's session, creating it (and its cookie) if needed.
func sessionFor(w http.ResponseWriter, r *http.Request, secure bool) *session {
mu.Lock()
defer mu.Unlock()
if c, err := r.Cookie("app_session"); err == nil && sessions[c.Value] != nil {
return sessions[c.Value]
}
id := rand.Text()
sessions[id] = &session{}
http.SetCookie(w, &http.Cookie{
Name: "app_session", Value: id, Path: "/", HttpOnly: true, Secure: secure, SameSite: http.SameSiteLaxMode,
})
return sessions[id]
}
func main() {
ctx := context.Background()
appURL := os.Getenv("APP_URL")
clientID := os.Getenv("IDRA_CLIENT_ID")
secret := os.Getenv("IDRA_CLIENT_SECRET")
secure := len(appURL) > 5 && appURL[:5] == "https"
provider, err := oidc.NewProvider(ctx, os.Getenv("IDRA_ISSUER"))
if err != nil {
log.Fatal(err)
}
var meta struct {
EndSession string `json:"end_session_endpoint"`
}
if err := provider.Claims(&meta); err != nil {
log.Fatal(err)
}
endpoint := provider.Endpoint()
// With a secret: client_secret_basic. Without one: a public client.
endpoint.AuthStyle = oauth2.AuthStyleInParams
if secret != "" {
endpoint.AuthStyle = oauth2.AuthStyleInHeader
}
conf := oauth2.Config{
ClientID: clientID,
ClientSecret: secret,
Endpoint: endpoint,
RedirectURL: appURL + "/callback",
Scopes: []string{oidc.ScopeOpenID, "profile", "email", "groups"},
}
verifier := provider.Verifier(&oidc.Config{ClientID: clientID})
http.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
s := sessionFor(w, r, secure)
w.Header().Set("Content-Type", "text/html")
if s.User == nil {
fmt.Fprint(w, `<h1>Signed out</h1><a href="/login">Sign in with IdRa</a>`)
return
}
body, _ := json.MarshalIndent(s.User, "", " ")
fmt.Fprintf(w, `<h1>Signed in</h1><pre>%s</pre><a href="/logout">Sign out</a>`, html.EscapeString(string(body)))
})
http.HandleFunc("GET /login", func(w http.ResponseWriter, r *http.Request) {
s := sessionFor(w, r, secure)
mu.Lock()
s.Verifier, s.State, s.Nonce = oauth2.GenerateVerifier(), rand.Text(), rand.Text()
url := conf.AuthCodeURL(s.State, oauth2.S256ChallengeOption(s.Verifier), oidc.Nonce(s.Nonce))
mu.Unlock()
http.Redirect(w, r, url, http.StatusFound)
})
http.HandleFunc("GET /callback", func(w http.ResponseWriter, r *http.Request) {
s := sessionFor(w, r, secure)
mu.Lock()
verifierValue, state, nonce := s.Verifier, s.State, s.Nonce
mu.Unlock()
user, idToken, err := func() (map[string]any, string, error) {
if q := r.URL.Query(); q.Get("state") != state || state == "" {
return nil, "", fmt.Errorf("state mismatch")
} else if e := q.Get("error"); e != "" {
return nil, "", fmt.Errorf("%s: %s", e, q.Get("error_description"))
}
token, err := conf.Exchange(r.Context(), r.URL.Query().Get("code"), oauth2.VerifierOption(verifierValue))
if err != nil {
return nil, "", err
}
raw, _ := token.Extra("id_token").(string)
idt, err := verifier.Verify(r.Context(), raw)
if err != nil {
return nil, "", err
}
if idt.Nonce != nonce {
return nil, "", fmt.Errorf("nonce mismatch")
}
// The ID token has no email or name: they come from UserInfo.
info, err := provider.UserInfo(r.Context(), oauth2.StaticTokenSource(token))
if err != nil {
return nil, "", err
}
var claims map[string]any
if err := info.Claims(&claims); err != nil {
return nil, "", err
}
return claims, raw, nil
}()
if err != nil {
log.Print(err)
http.Error(w, "Sign-in failed: "+err.Error(), http.StatusBadRequest)
return
}
mu.Lock()
s.User, s.IDToken = user, idToken
mu.Unlock()
http.Redirect(w, r, "/", http.StatusFound)
})
http.HandleFunc("GET /logout", func(w http.ResponseWriter, r *http.Request) {
s := sessionFor(w, r, secure)
mu.Lock()
idToken := s.IDToken
s.User, s.IDToken = nil, ""
mu.Unlock()
if idToken == "" {
http.Redirect(w, r, "/", http.StatusFound)
return
}
q := url.Values{"id_token_hint": {idToken}, "post_logout_redirect_uri": {appURL + "/"}}
http.Redirect(w, r, meta.EndSession+"?"+q.Encode(), http.StatusFound)
})
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
log.Printf("Listening on %s", appURL)
log.Fatal(http.ListenAndServe(":"+port, nil))
}Run
set -a; source .env; set +a
go run .Verify sign-in and sign-out
- Open
http://localhost:8080and select Sign in with IdRa. You land on IdRa's sign-in page. - Sign in, with MFA if your policies ask for it. You come back to a Signed in page showing your claims.
- Select Sign out. You return to Signed out. This also ends the IdRa session, so the next sign-in asks for your credentials again.
Reports → System Log in the admin console shows the sign-in.
What the app receives
The ID token doesn't carry email or name. Read them from UserInfo, as this sample does.
| Claim | ID token | UserInfo | Scope |
|---|---|---|---|
sub | Yes | Yes | openid |
preferred_username (the email), updated_at | Yes | Yes | profile |
name, given_name, family_name | No | Yes | profile |
email, email_verified | No | Yes | email |
groups (group names, always including Everyone) | Yes | Yes | groups |
Add offline_access to the scopes to receive a refresh token. Access tokens last an hour. See OpenID Connect for the rest.
Troubleshooting
| What you see | Cause | Fix |
|---|---|---|
Use https on a public host, or for local development http on localhost, 127.0.0.1 or [::1] with a port (…) | The redirect URI is http on another host or without a port, or https on a loopback host. | Use http://localhost:8080/callback locally and https on a public host in production. |
Sign-in error: invalid redirect uri | The app sent a redirect URI that isn't registered. Matching is exact, including port and path. | Make it match Sign-in redirect URIs on the Sign On tab. |
Access to … denied: You are not assigned to this application. | The person isn't assigned. | Assign them on Assignments. |
Access to … denied: This application is currently inactive. | The app is deactivated. | Activate it on the app's page. |
Access denied: This application is not registered with your organization. | Wrong client ID, or a client ID from another organization. | Copy the Client ID from Sign On. |
Access denied: Your organization's sign-on policy does not allow this access., URL has code=invalid_scope | The app asked for a scope that doesn't exist. | Use openid, profile, email, groups, offline_access or a scope defined under Security → API. |
pkce is required for this client | The authorize request has no PKCE challenge. | Send code_challenge with code_challenge_method=S256. IdRa accepts only S256. |
invalid_client: invalid client_secret | Wrong secret, or it was rotated. | Use Generate new secret on Sign On. |
invalid_client: client registered for client_secret_basic cannot use none | A Web Application is running without its secret. | Set IDRA_CLIENT_SECRET. |
invalid_client: client registered for client_secret_basic cannot use client_secret_post | The library sent the secret in the body. | Send it as HTTP Basic, or change Client authentication on Sign On. |
invalid_grant: redirect_uri mismatch | The token request's redirect URI differs from the authorize request's. | Use the same value in both. |
invalid_grant: code verification failed | The PKCE verifier doesn't match the challenge. | Keep the verifier from the authorize step, per sign-in. |
404 on discovery | The issuer is missing /api/auth. | Use the Issuer from Sign On: https://<your-org>.idra.team/api/auth. |
Page says Logged out. The requested post-logout redirect was not registered. | The person was signed out, but the return URL isn't registered. | Add it to Sign-out redirect URIs. |
| Confirm logout page | No id_token_hint, or it's for a different session than the browser's. | Send the ID token as the hint. Without one, the page is the expected safeguard. |
Reports → System Log records refused access as app.access with the reason.