Go

Go

Sign in with NIA — Go

Add “Sign in with NIA” to a Go service with coreos/go-oidc and golang.org/x/oauth2, using the standard OpenID Connect Authorization Code + PKCE flow.

~20 minMediumcoreos/go-oidc + oauth2
Try OAuth Flow
Integration progress
0/16

01Overview

This guide adds “Sign in with NIA” to a Go application using the standard OpenID Connect flow. NIA is a conformant OIDC provider, so you use coreos/go-oidc together with golang.org/x/oauth2 — no NIA-specific SDK required.

Go runs as a confidential client: the browser is redirected to NIA to sign in, and your Go server exchanges the returned code for tokens using your client secret. go-oidc discovers NIA's endpoints and validates ID tokens against its JWKS; oauth2 handles the authorization-code exchange and PKCE.

  • Flow: Authorization Code + PKCE (S256)
  • Tokens: RS256 access & ID tokens, opaque refresh token
  • You write: two handlers — login and callback — plus a session middleware

02Requirements

  • Go 1.21+
  • A NIA account with access to the client dashboard
  • A registered OAuth client (client_id + client_secret)
  • Your app's redirect URI registered on that client
  • A way to persist session state (cookie store, e.g. gorilla/sessions)

03Installation

Add the OIDC and OAuth2 modules to your go.mod. go-oidc fetches the discovery document and JWKS over HTTP; oauth2 drives the token exchange.

go get
go get github.com/coreos/go-oidc/v3/oidc golang.org/x/oauth2

04Required OAuth library

Create the OIDC provider once at startup by pointing it at NIA's issuer. The provider fetches the discovery document and exposes NIA's oauth2.Endpoint; wrap it in an oauth2.Config and derive a token verifier from it.

auth/nia.go
package auth

import (
	"context"
	"os"

	"github.com/coreos/go-oidc/v3/oidc"
	"golang.org/x/oauth2"
)

var (
	Provider     *oidc.Provider
	OAuth2Config oauth2.Config
	Verifier     *oidc.IDTokenVerifier
)

func Init(ctx context.Context) error {
	provider, err := oidc.NewProvider(ctx, os.Getenv("NIA_ISSUER"))
	if err != nil {
		return err
	}

	OAuth2Config = oauth2.Config{
		ClientID:     os.Getenv("NIA_CLIENT_ID"),
		ClientSecret: os.Getenv("NIA_CLIENT_SECRET"),
		RedirectURL:  "http://localhost:8080/auth/callback",
		Endpoint:     provider.Endpoint(),
		Scopes:       []string{oidc.ScopeOpenID, "email", "profile"},
	}

	Provider = provider
	Verifier = provider.Verifier(&oidc.Config{ClientID: OAuth2Config.ClientID})
	return nil
}
Add offline_access to Scopes only if you need refresh tokens (see step 13).

05Environment Variables

Never hard-code secrets. Load them from the environment (e.g. with joho/godotenv in development) before calling auth.Init.

.env
NIA_ISSUER=https://dev-api.niaauth.com
NIA_CLIENT_ID=your-client-id
NIA_CLIENT_SECRET=your-client-secret
SESSION_SECRET=replace-with-a-long-random-value

06Register OAuth Client

In the NIA dashboard, create the client your Go app will authenticate as:

  • Open Dashboard → Clients → New client
  • Client type: Web (confidential)
  • Allowed scopes: openid email profile (add offline_access for refresh)
  • Add your redirect URI (next step)
  • Copy the client_id and client_secret — the secret is shown once

07Redirect URI

NIA matches redirect URIs exactly — scheme, host, port, and path must be identical, with no trailing-slash difference. Register one URI per environment.

redirect URIs
# Development
http://localhost:8080/auth/callback

# Production
https://app.example.com/auth/callback
A mismatch is the #1 integration error. http://localhost:8080/auth/callback/ (trailing slash) will be rejected.

08Issuer

The issuer is the single URL every OIDC library needs. Its discovery document advertises all endpoints below.

issuer & endpoints
Issuer:        https://dev-api.niaauth.com
Discovery:     https://dev-api.niaauth.com/.well-known/openid-configuration
Authorization: https://dev-api.niaauth.com/oauth/authorize
Token:         https://dev-api.niaauth.com/oauth/token
UserInfo:      https://dev-api.niaauth.com/oauth/userinfo
JWKS:          https://dev-api.niaauth.com/oauth/jwks.json

09Authorization Flow

Add a login handler that generates state and a PKCE code verifier, stores both in a cookie, and redirects to NIA. x/oauth2's built-in PKCE helpers do the challenge computation for you.

auth/handlers.go
package auth

import (
	"crypto/rand"
	"encoding/base64"
	"net/http"

	"golang.org/x/oauth2"
)

func randString() string {
	b := make([]byte, 24)
	rand.Read(b)
	return base64.RawURLEncoding.EncodeToString(b)
}

func LoginHandler(w http.ResponseWriter, r *http.Request) {
	state := randString()
	verifier := oauth2.GenerateVerifier()

	// Store state + verifier so the callback can validate them.
	http.SetCookie(w, &http.Cookie{Name: "nia_state", Value: state, Path: "/", HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode, MaxAge: 600})
	http.SetCookie(w, &http.Cookie{Name: "nia_verifier", Value: verifier, Path: "/", HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode, MaxAge: 600})

	url := OAuth2Config.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.S256ChallengeOption(verifier))
	http.Redirect(w, r, url, http.StatusFound)
}

10Token Exchange

On callback, verify state, exchange the code with the stored PKCE verifier, and extract the raw ID token from the response before verifying it.

auth/handlers.go
func CallbackHandler(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()

	stateCookie, err := r.Cookie("nia_state")
	if err != nil || r.URL.Query().Get("state") != stateCookie.Value {
		http.Error(w, "invalid state", http.StatusBadRequest)
		return
	}
	verifierCookie, err := r.Cookie("nia_verifier")
	if err != nil {
		http.Error(w, "missing pkce verifier", http.StatusBadRequest)
		return
	}

	oauth2Token, err := OAuth2Config.Exchange(
		ctx,
		r.URL.Query().Get("code"),
		oauth2.VerifierOption(verifierCookie.Value),
	)
	if err != nil {
		http.Error(w, "token exchange failed", http.StatusInternalServerError)
		return
	}

	rawIDToken, ok := oauth2Token.Extra("id_token").(string)
	if !ok {
		http.Error(w, "no id_token in response", http.StatusInternalServerError)
		return
	}

	idToken, err := Verifier.Verify(ctx, rawIDToken)
	if err != nil {
		http.Error(w, "id_token verification failed", http.StatusUnauthorized)
		return
	}

	var claims struct {
		Sub     string `json:"sub"`
		Email   string `json:"email"`
		Name    string `json:"name"`
	}
	if err := idToken.Claims(&claims); err != nil {
		http.Error(w, "failed to parse claims", http.StatusInternalServerError)
		return
	}

	SaveSession(w, r, claims.Sub, claims.Email, claims.Name, oauth2Token)
	http.Redirect(w, r, "/", http.StatusFound)
}

11JWKS Validation

NIA signs ID tokens with RS256. provider.Verifier(...).Verify validates the id_token against NIA's JWKS (fetched from discovery), checking the signature, iss, aud, and exp automatically — you never verify manually.

verify (illustrative)
idToken, err := Verifier.Verify(ctx, rawIDToken)
if err != nil {
	// signature (RS256, via JWKS), issuer, audience and expiry
	// have already been checked — err tells you which one failed.
	return err
}
// idToken.Issuer == os.Getenv("NIA_ISSUER")
// idToken.Audience contains OAuth2Config.ClientID
Keys rotate. Because go-oidc refetches JWKS by kid, rotation needs no code change on your side.

12Session Management

Store the user and tokens in a server-side session (e.g. gorilla/sessions), then guard handlers with a small middleware.

package auth

import (
	"net/http"

	"github.com/gorilla/sessions"
	"golang.org/x/oauth2"
)

var Store = sessions.NewCookieStore([]byte(os.Getenv("SESSION_SECRET")))

func SaveSession(w http.ResponseWriter, r *http.Request, sub, email, name string, token *oauth2.Token) {
	sess, _ := Store.Get(r, "nia-session")
	sess.Values["sub"] = sub
	sess.Values["email"] = email
	sess.Values["name"] = name
	sess.Values["id_token"] = token.Extra("id_token")
	sess.Values["refresh_token"] = token.RefreshToken
	sess.Save(r, w)
}

13Refresh Tokens

If you requested offline_access, NIA returns a refresh token. Use OAuth2Config.TokenSource to get an automatically-refreshing source, or exchange the refresh token manually. NIA rotates refresh tokens, so always persist the new one.

auth/refresh.go
import "golang.org/x/oauth2"

// Automatic: TokenSource refreshes transparently when the access token expires.
func TokenSourceFor(ctx context.Context, refreshToken string) oauth2.TokenSource {
	return OAuth2Config.TokenSource(ctx, &oauth2.Token{RefreshToken: refreshToken})
}

// Manual: exchange the refresh token yourself.
func RefreshManually(ctx context.Context, refreshToken string) (*oauth2.Token, error) {
	src := OAuth2Config.TokenSource(ctx, &oauth2.Token{RefreshToken: refreshToken})
	newToken, err := src.Token()
	if err != nil {
		return nil, err
	}
	// NIA rotates the refresh token — persist newToken.RefreshToken.
	return newToken, nil
}

14Logout

Clear the session cookie and send the user to NIA's end-session endpoint so they are signed out of NIA too. Register your post_logout_redirect_uri on the client.

auth/handlers.go
func LogoutHandler(w http.ResponseWriter, r *http.Request) {
	sess, _ := Store.Get(r, "nia-session")
	idToken, _ := sess.Values["id_token"].(string)

	sess.Options.MaxAge = -1
	sess.Save(r, w)

	q := url.Values{}
	q.Set("id_token_hint", idToken)
	q.Set("post_logout_redirect_uri", "http://localhost:8080/")

	http.Redirect(w, r, os.Getenv("NIA_ISSUER")+"/oauth/logout?"+q.Encode(), http.StatusFound)
}

15Deployment

Before going live:

  • Register the production redirect URI (HTTPS) on the client
  • Set all env vars via your host's secret manager — never commit .env
  • Serve over HTTPS and set Secure: true on all cookies
  • Use a shared session store (e.g. Redis) behind multiple instances
  • Update OAuth2Config.RedirectURL to the production callback URL
auth/nia.go (production)
OAuth2Config.RedirectURL = "https://app.example.com/auth/callback"

// Cookies must be Secure + SameSite=Lax behind HTTPS/a reverse proxy.
http.SetCookie(w, &http.Cookie{
	Name:     "nia_state",
	Value:    state,
	Path:     "/",
	HttpOnly: true,
	Secure:   true,
	SameSite: http.SameSiteLaxMode,
})

16Troubleshooting

  • redirect_uri_mismatch — the URI must match the registered one exactly (no trailing slash, correct scheme/port).
  • invalid_client — wrong NIA_CLIENT_SECRET, or the client's token-auth method differs from what oauth2.Config sends.
  • state/verifier cookie lost — the nia_state/nia_verifier cookies didn't survive the redirect. Check SameSite, Secure, and cookie domain settings.
  • invalid_grant / PKCE failed — the code verifier sent at token exchange doesn't match the challenge sent at login; usually a lost or overwritten cookie.
  • id_token audience mismatch — Verifier was built with a different ClientID than the one used to start the flow.
  • ID-token signature / clock skew — sync server time via NTP; go-oidc rejects tokens outside their validity window.