// Sign in with the EU Digital Identity Wallet - Go (go-oidc v3 + x/oauth2) // CodeB acts as the verifier: it asks the user's wallet for a presentation, // verifies it, and hands your app a signed OpenID Connect id_token. // // go get github.com/coreos/go-oidc/v3/oidc golang.org/x/oauth2 // CODEB_CLIENT_ID=... CODEB_CLIENT_SECRET=... go run wallet-signin-go.go // // Register https:///callback as redirect URI for your client first. // Sessions are kept in memory to keep the sample short; use your own session store. package main import ( "context" "crypto/rand" "encoding/base64" "fmt" "html" "log" "net/http" "os" "sync" "time" "github.com/coreos/go-oidc/v3/oidc" "golang.org/x/oauth2" ) var walletAcrs = map[string]bool{"urn:codeb:acr:eudi-wallet": true, "urn:codeb:acr:eudi-wallet-linked": true} type pending struct { verifier, nonce string created time.Time } var ( mu sync.Mutex inflight = map[string]pending{} // state -> PKCE verifier + nonce (10 minutes) users = map[string]string{} // session id -> "sub (acr)" ) func env(k, d string) string { if v := os.Getenv(k); v != "" { return v } return d } func random() string { b := make([]byte, 32) if _, err := rand.Read(b); err != nil { panic(err) } return base64.RawURLEncoding.EncodeToString(b) } func main() { ctx := context.Background() appBase := env("APP_BASE_URL", "http://localhost:3000") provider, err := oidc.NewProvider(ctx, env("CODEB_ISSUER", "https://phone.aloaha.com")) if err != nil { log.Fatal(err) } clientID := env("CODEB_CLIENT_ID", "") conf := oauth2.Config{ ClientID: clientID, ClientSecret: env("CODEB_CLIENT_SECRET", ""), Endpoint: provider.Endpoint(), RedirectURL: appBase + "/callback", Scopes: []string{oidc.ScopeOpenID, "profile", "email"}, } verifier := provider.Verifier(&oidc.Config{ClientID: clientID}) http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { if c, err := r.Cookie("sid"); err == nil { mu.Lock() u, ok := users[c.Value] mu.Unlock() if ok { fmt.Fprint(w, "Signed in as "+html.EscapeString(u)) return } } fmt.Fprint(w, `Sign in with your EU Digital Identity Wallet`) }) http.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) { state, nonce, pkce := random(), random(), oauth2.GenerateVerifier() mu.Lock() for k, v := range inflight { // drop abandoned sign-ins if time.Since(v.created) > 10*time.Minute { delete(inflight, k) } } inflight[state] = pending{pkce, nonce, time.Now()} mu.Unlock() // Bind the state to this browser: the callback only accepts the state from its own cookie. http.SetCookie(w, &http.Cookie{Name: "oidc_state", Value: state, Path: "/callback", MaxAge: 600, HttpOnly: true, SameSite: http.SameSiteLaxMode}) http.Redirect(w, r, conf.AuthCodeURL(state, oidc.Nonce(nonce), oauth2.S256ChallengeOption(pkce), oauth2.SetAuthURLParam("acr_values", "urn:codeb:acr:eudi-wallet"), // straight to the wallet sign-in ), http.StatusFound) }) http.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) { state := r.URL.Query().Get("state") c, err := r.Cookie("oidc_state") if err != nil || c.Value != state { http.Error(w, "state does not belong to this browser", http.StatusBadRequest) return } http.SetCookie(w, &http.Cookie{Name: "oidc_state", Value: "", Path: "/callback", MaxAge: -1}) mu.Lock() p, ok := inflight[state] delete(inflight, state) mu.Unlock() if !ok || time.Since(p.created) > 10*time.Minute { http.Error(w, "unknown state", http.StatusBadRequest) return } tok, err := conf.Exchange(r.Context(), r.URL.Query().Get("code"), oauth2.VerifierOption(p.verifier)) if err != nil { http.Error(w, "token exchange failed", http.StatusBadGateway) return } raw, _ := tok.Extra("id_token").(string) idt, err := verifier.Verify(r.Context(), raw) // signature, issuer, audience, expiry if err != nil || idt.Nonce != p.nonce { http.Error(w, "invalid id_token", http.StatusUnauthorized) return } var claims struct { Acr string `json:"acr"` } if err := idt.Claims(&claims); err != nil || !walletAcrs[claims.Acr] { http.Error(w, "This page needs a sign-in with the EU Digital Identity Wallet (acr="+claims.Acr+").", http.StatusForbidden) return } sid := random() mu.Lock() users[sid] = idt.Subject + " (acr=" + claims.Acr + ")" mu.Unlock() http.SetCookie(w, &http.Cookie{Name: "sid", Value: sid, Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode}) http.Redirect(w, r, "/", http.StatusFound) }) log.Fatal(http.ListenAndServe(":"+env("PORT", "3000"), nil)) }