← Integration guides

Go integration

A confidential client — your Go service holds a real client_secret. Uses the standard library's net/http plus gorilla/sessions for server-side cookie session state. Redirects to this platform's real hosted login page — see the integration guides overview — which shows every currently-real first-factor option; you never build a login form. Two different hosts are used below: accounts.onehux.com for the hosted login/logout pages a browser is redirected to, and api-accounts.onehux.com for the actual API calls your backend makes server-to-server — mixing these up is the single most common integration mistake, since the wrong host doesn't error loudly, it just 404s.

Prefer the SDK — it already implements everything below (PKCE, the redirect, callback handling, token exchange, automatic refresh-token rotation, `/userinfo`, RP-initiated logout, OIDC Back-Channel Logout, and the public application launcher), real and maintained, rather than hand-rolling the raw API calls this page walks through manually:

go get github.com/Onehux/onehux-sso-go

pkg.go.dev/github.com/Onehux/onehux-sso-go — the manual walkthrough below is still worth reading to understand what the SDK is doing on your behalf, and remains the reference if you're integrating in a language without an official SDK.

1. Start the flow — PKCE + redirect

// auth.go
package main

import (
	"crypto/rand"
	"crypto/sha256"
	"encoding/base64"
	"net/http"
	"net/url"
	"os"

	"github.com/gorilla/sessions"
)

const onehuxLoginBaseURL = "https://your-org.onehux.com"     // see "Two different hosts" above
const onehuxAPIBaseURL = "https://api.your-domain.com"       // see "Two different hosts" above
const redirectURI = "https://yourapp.example.com/auth/callback"

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

func b64url(b []byte) string {
	return base64.RawURLEncoding.EncodeToString(b)
}

func handleLogin(w http.ResponseWriter, r *http.Request) {
	verifierBytes := make([]byte, 48)
	rand.Read(verifierBytes)
	codeVerifier := b64url(verifierBytes)

	challenge := sha256.Sum256([]byte(codeVerifier))
	codeChallenge := b64url(challenge[:])

	stateBytes := make([]byte, 16)
	rand.Read(stateBytes)
	state := b64url(stateBytes)

	session, _ := store.Get(r, "onehux-auth")
	// PKCE verifier + state live server-side in the session — never in a cookie the
	// browser can read, never round-tripped through the client.
	session.Values["pkce_verifier"] = codeVerifier
	session.Values["oauth_state"] = state
	session.Save(r, w)

	params := url.Values{
		"client_id":             {os.Getenv("ONEHUX_CLIENT_ID")},
		"redirect_uri":          {redirectURI},
		"code_challenge":        {codeChallenge},
		"code_challenge_method": {"S256"},
		"scope":                 {"openid profile email"},
		"state":                 {state},
	}
	http.Redirect(w, r, onehuxLoginBaseURL+"/login?"+params.Encode(), http.StatusFound)
}

2. Callback — verify state, exchange the code

import (
	"bytes"
	"encoding/json"
)

type tokenResponse struct {
	AccessToken  string `json:"access_token"`
	IDToken      string `json:"id_token"`
	RefreshToken string `json:"refresh_token"`
	TokenType    string `json:"token_type"`
	ExpiresIn    int    `json:"expires_in"`
	Scope        string `json:"scope"`
}

func handleCallback(w http.ResponseWriter, r *http.Request) {
	code := r.URL.Query().Get("code")
	state := r.URL.Query().Get("state")

	session, _ := store.Get(r, "onehux-auth")
	expectedState, _ := session.Values["oauth_state"].(string)
	codeVerifier, _ := session.Values["pkce_verifier"].(string)
	delete(session.Values, "oauth_state")
	delete(session.Values, "pkce_verifier")

	if code == "" || state == "" || state != expectedState {
		http.Error(w, "Invalid or missing OAuth state.", http.StatusBadRequest)
		return
	}

	body, _ := json.Marshal(map[string]string{
		"grant_type":    "authorization_code",
		"code":          code,
		"redirect_uri":  redirectURI,
		"client_id":     os.Getenv("ONEHUX_CLIENT_ID"),
		"client_secret": os.Getenv("ONEHUX_CLIENT_SECRET"),
		"code_verifier": codeVerifier,
	})
	resp, err := http.Post(onehuxAPIBaseURL+"/api/v1/oauth/token/", "application/json", bytes.NewReader(body))
	if err != nil || resp.StatusCode != http.StatusOK {
		http.Error(w, "Sign-in failed.", http.StatusBadRequest)
		return
	}
	defer resp.Body.Close()

	var tokens tokenResponse
	json.NewDecoder(resp.Body).Decode(&tokens)

	// Session establishment — the access token lives server-side only (the signed
	// cookie session store), the same BFF discipline this platform's own dashboard
	// follows on itself. The browser only ever gets the session cookie, never the raw
	// token.
	session.Values["access_token"] = tokens.AccessToken
	session.Values["refresh_token"] = tokens.RefreshToken
	session.Save(r, w)
	http.Redirect(w, r, "/dashboard", http.StatusFound)
}

3. Using the token — and refreshing it

A confidential client's refresh token rotates on every use and is itself long-lived (30-day idle timeout, 30-day absolute cap — see Sessions), so a 401 from /userinfo almost always means just the 15-minute access token expired, not that the user needs to sign in again:

func refreshTokens(w http.ResponseWriter, r *http.Request, session *sessions.Session) string {
	refreshToken, ok := session.Values["refresh_token"].(string)
	if !ok || refreshToken == "" {
		return ""
	}

	body, _ := json.Marshal(map[string]string{
		"grant_type":    "refresh_token",
		"refresh_token": refreshToken,
		"client_id":     os.Getenv("ONEHUX_CLIENT_ID"),
		"client_secret": os.Getenv("ONEHUX_CLIENT_SECRET"),
	})
	resp, err := http.Post(onehuxAPIBaseURL+"/api/v1/oauth/token/", "application/json", bytes.NewReader(body))
	if err != nil || resp.StatusCode != http.StatusOK {
		return ""
	}
	defer resp.Body.Close()

	var tokens tokenResponse
	json.NewDecoder(resp.Body).Decode(&tokens)
	session.Values["access_token"] = tokens.AccessToken
	session.Values["refresh_token"] = tokens.RefreshToken
	session.Save(r, w)
	return tokens.AccessToken
}

func handleMe(w http.ResponseWriter, r *http.Request) {
	session, _ := store.Get(r, "onehux-auth")
	accessToken, ok := session.Values["access_token"].(string)
	if !ok || accessToken == "" {
		http.Redirect(w, r, "/auth/login", http.StatusFound)
		return
	}

	req, _ := http.NewRequest("GET", onehuxAPIBaseURL+"/api/v1/oauth/userinfo/", nil)
	req.Header.Set("Authorization", "Bearer "+accessToken)
	resp, err := http.DefaultClient.Do(req)
	if err == nil && resp.StatusCode == http.StatusUnauthorized {
		accessToken = refreshTokens(w, r, session)
		if accessToken == "" {
			delete(session.Values, "access_token")
			delete(session.Values, "refresh_token")
			session.Save(r, w)
			http.Redirect(w, r, "/auth/login", http.StatusFound)
			return
		}
		req, _ = http.NewRequest("GET", onehuxAPIBaseURL+"/api/v1/oauth/userinfo/", nil)
		req.Header.Set("Authorization", "Bearer "+accessToken)
		resp, err = http.DefaultClient.Do(req)
	}
	if err != nil || resp.StatusCode != http.StatusOK {
		http.Redirect(w, r, "/auth/login", http.StatusFound)
		return
	}
	defer resp.Body.Close()

	w.Header().Set("Content-Type", "application/json")
	io.Copy(w, resp.Body) // sub, name, email, picture, roles, permissions, ...
}

Logging out — RP-initiated, real SLO

Redirect to end the platform-wide session, not just clear your own local one — every other Application relying on that same session is signed out too:

func handleLogout(w http.ResponseWriter, r *http.Request) {
	session, _ := store.Get(r, "onehux-auth")
	delete(session.Values, "access_token")
	delete(session.Values, "refresh_token")
	session.Save(r, w)

	params := url.Values{
		"client_id":                 {os.Getenv("ONEHUX_CLIENT_ID")},
		"post_logout_redirect_uri": {"https://yourapp.example.com/logged-out"},
	}
	http.Redirect(w, r, onehuxLoginBaseURL+"/end-session?"+params.Encode(), http.StatusFound)
}

Register this URL too: post_logout_redirect_uri is validated against the exact same registered redirect_uris list as your login callback — if https://yourapp.example.com/logged-out isn't in that list too, /end-session rejects the request with a real 400, even though your login callback works fine.

The redirect above only notifies the app the user actually clicked "log out" in. If they instead log out of a different app, or directly at accounts.onehux.com, the shared session is still revoked immediately and correctly — and if this Application has registered a Back-Channel Logout endpoint, this platform pushes it a real, spec-compliant logout_token (server-to-server, HS256-signed with a dedicated backchannel signing secret — deliberately not your OAuth client_secret) as soon as the revocation happens, rather than waiting for your app to notice on its own:

PATCH /api/v1/applications/{id}/backchannel-logout/
{ "backchannel_logout_uri": "https://yourapp.example.com/auth/backchannel-logout" }

-> { "backchannel_logout_uri": "...", "backchannel_logout_secret": "..." }  (shown once)

Receiving and verifying that push is its own integration surface — not covered by this walkthrough. Without registering it, your app's own local session keeps showing "signed in" until its next real /userinfo call fails, bounded by the access token's 15-minute lifetime. Don't treat a locally-held session as a live signal of the IdP's true logout state unless you've wired up the push.