onehuxsso

package module
v0.2.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 24, 2026 License: Apache-2.0 Imports: 14 Imported by: 0

README

onehux-sso-go

A real, installable Go SDK wrapping OneHux Accounts' Authorization Code + PKCE flow against its real hosted login page — the same shape and behavior as this project's Django/Node.js/Laravel SDKs, adapted to Go's standard library rather than any one framework (Go has no single dominant web/session framework the way those three languages do).

Two layers, in one package:

  • OneHuxClient — the framework-agnostic client (PKCE, token exchange, /userinfo, logout URL, OIDC Back-Channel Logout verification). No dependency on net/http handler wiring at all — usable from any Go web framework or a plain script.
  • Handlers — real, runnable net/http.HandlerFuncs wiring OneHuxClient to a SessionStore (an interface this package defines, with one working MemorySessionStore implementation shipped). Only use this if you want the five ready-made routes; wire OneHuxClient directly otherwise.

Zero non-stdlib dependencies.

Install

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

Two hosts — don't mix them up

accounts.onehux.com serves the hosted login/logout pages a browser is redirected to. api-accounts.onehux.com serves the actual OAuth API your backend calls server-to-server. This package keeps them as two separate options (LoginBaseURL / APIBaseURL) precisely because collapsing them into one host was a real, confirmed bug in the original integration guides (see the backend repo's README.md, ADR-070) — the wrong host doesn't error loudly, it silently 404s.

If your Organization has a live custom domain (Dashboard → Settings → Branding, see the backend repo's README.md ADR-027), set LoginBaseURL to that domain instead — it's what your end users' browsers actually land on, so it should match whatever you've branded. Never override APIBaseURL: it has no per-Organization customization and never needs any — every call there is server-to-server via your ClientID/ClientSecret, never seen by an end user.

Setup — using Handlers

  1. Register a real confidential-client Application in your OneHux Accounts Organization (Dashboard → Applications), with a redirect_uri pointing at wherever you mount this package's /callback route, and your post_logout_redirect_uri registered in that same list — OneHux Accounts validates both against the one redirect_uris list, not two separate ones.

  2. Wire it up:

    package main
    
    import (
        "log"
        "net/http"
        "os"
    
        onehuxsso "github.com/Onehux/onehux-sso-go"
    )
    
    func main() {
        client := onehuxsso.NewClient(onehuxsso.ClientOptions{
            ClientID:              os.Getenv("ONEHUX_CLIENT_ID"),
            ClientSecret:          os.Getenv("ONEHUX_CLIENT_SECRET"),
            RedirectURI:           "https://yourapp.example.com/auth/callback",
            PostLogoutRedirectURI: "https://yourapp.example.com/auth/logged-out",
            // LoginBaseURL / APIBaseURL / Scope all have real production defaults.
        })
    
        store := onehuxsso.NewMemorySessionStore(true) // true: Secure cookie flag (HTTPS)
        handlers := onehuxsso.NewHandlers(onehuxsso.HandlersOptions{
            Client: client,
            Store:  store,
        })
    
        mux := http.NewServeMux()
        handlers.Mount(mux, "/auth")
        log.Fatal(http.ListenAndServe(":8080", mux))
    }
    

    This gives you five real, working routes: /auth/login, /auth/callback, /auth/logout, /auth/userinfo (a ready-to-use JSON endpoint your own frontend can call with credentials included, matching the BFF pattern documented for the web-frontend integration guide — your frontend never talks to OneHux directly), and /auth/backchannel-logout (only does anything once you configure it — see "Logging out" below).

MemorySessionStore is a real, working implementation — correct for a single-process deployment. A multi-process production deployment should supply its own SessionStore implementation (the interface is four methods) backed by shared storage (Redis, a database, ...).

Using the client directly (any framework, or a custom flow)

client := onehuxsso.NewClient(onehuxsso.ClientOptions{ /* ...same options as above... */ })

pending, err := client.StartAuthorization()
// stash pending.State / pending.CodeVerifier in your own session, then redirect the browser
// to pending.AuthorizationURL

tokens, err := client.ExchangeCode(code, state, session["onehux_sso_state"], session["onehux_sso_pkce_verifier"])
// tokens.RefreshToken: persist it server-side alongside tokens.AccessToken if you're not
// using Handlers (which already does this for you) — see "Refresh tokens" below.

claims, err := client.GetUserinfo(tokens.AccessToken)
var expiredErr *onehuxsso.TokenExpiredError
if errors.As(err, &expiredErr) {
	// GetUserinfo never retries itself (it's a pure API call, no session concept) — a caller
	// using OneHuxClient directly owns this retry, same as Handlers.UserinfoHandler does
	// internally. See "Refresh tokens" below.
	refreshed, refreshErr := client.RefreshAccessToken(session["onehux_sso_refresh_token"])
	if refreshErr != nil {
		// handle refreshErr — a real *TokenExpiredError means the session is genuinely dead
	}
	session["onehux_sso_refresh_token"] = refreshed.RefreshToken // rotated — persist the new one
	claims, err = client.GetUserinfo(refreshed.AccessToken)
}

logoutURL := client.BuildLogoutURL("")

Public application launcher

GET /api/v1/organizations/{orgSlug}/public-applications/ is a real, public, unauthenticated platform endpoint — no ClientID/ClientSecret involved, usable for any Organization by its own slug, not just your own configured one. It returns only Name/LogoURL/HomeURL for Applications that Organization has opted into public listing — a pure "what can I launch" list, never a way to start a sign-in flow.

apps, err := client.GetPublicApplications("onehux")
// [{Name: "ODS", LogoURL: "https://...", HomeURL: "https://..."}]

Rendering is entirely up to you — this package ships the data method only, no UI component (Go has no standard templating/UI convention to build one against). A plain, unstyled illustration (adapt this to your own design, don't copy it as-is):

{{range .Applications}}
  <a href="{{.HomeURL}}"><img src="{{.LogoURL}}" alt="{{.Name}}">{{.Name}}</a>
{{end}}

Logging out — what the user actually sees

There are two different triggers, and — once you wire up back-channel logout (below) — they produce the same fast, correct result. Understanding both is still worth it, since the second one only becomes immediate if you actually complete the setup:

1. The user clicks "Log out" inside your app (SP-initiated). Handlers.LogoutHandler (/auth/logout) clears its local session and redirects through /end-session in the same action, which ends the real, shared platform session immediately. From the user's point of view: they click Log out, land on your app's own logged-out page, and if they then open the dashboard or any other app, they're asked to log in again — everywhere, right away. This works cleanly because your own app is the one driving both halves of the logout at once, with no dependency on back-channel logout at all.

2. The user logs out somewhere else — a different app, or directly at accounts.onehux.com/the dashboard (IdP-initiated). The shared platform session is revoked immediately and correctly on the backend — same underlying revocation call as case 1. Whether your app finds out immediately depends entirely on whether you've completed the back-channel logout setup below:

  • With it wired up: OneHux POSTs a signed logout_token to your /auth/backchannel-logout route the instant the session is revoked. This package verifies it and destroys the matching local session server-side. From the user's point of view: functionally identical to case 1 — if they reload or navigate, they're asked to log in again right away, even though they never touched this app's own logout button.
  • Without it: your app has no way to find out proactively. It'll keep showing the user as signed in — its own local session cookie hasn't changed — right up until the moment it makes its next real call to /userinfo, which returns a real TokenExpiredError. In the worst realistic case, that's up to 15 minutes of stale "signed in" UI, bounded by the access token's own lifetime. This is not a security hole — no protected data actually leaks, since the real API call starts failing the moment it's tried — but the displayed state can look stale for that window.

To wire up back-channel logout:

  1. Pass BackchannelLogoutSigningSecret in HandlersOptions — this enables the POST /auth/backchannel-logout route (mounted automatically alongside the other four).
  2. Register that exact URL with OneHux:
    PATCH /api/v1/applications/{id}/backchannel-logout/
    { "backchannel_logout_uri": "https://yourapp.example.com/auth/backchannel-logout" }
    
    The response includes backchannel_logout_secret exactly once — this is a dedicated signing secret, deliberately not your ClientSecret (the backend stores that only as a one-way hash and can never read it back to sign anything with it). Use that value as BackchannelLogoutSigningSecret.
  3. If you run more than one process (a real production deployment almost certainly does), also supply a SidIndex implementation backed by shared storage (Redis, etc.) via HandlersOptions.SidIndex — the default MemorySidIndex only works within a single process, since the process that receives the logout_token POST may not be the same one that handled the original login.

If you're using OneHuxClient directly (no Handlers), call client.VerifyLogoutToken(logoutToken, signingSecret) yourself from wherever your framework routes the POST, then locate and destroy the matching local session using the returned payload's SID.

Spec: openid-connect-backchannel-1_0.

Refresh tokens

OneHux Accounts access tokens are a 15-minute, single-issue lifetime — that hasn't changed. What has: every real login now also issues a refresh token (backend repo README.md ADR-081, RFC 6749 §6 / RFC 9700 §4.14.2 rotation with reuse detection), which this package uses to renew an expired access token without a full re-login.

Handlers.UserinfoHandler (GET {prefix}/userinfo) does this automatically: an expired access token triggers exactly one silent client.RefreshAccessToken() call using the session's stored refresh token, and the caller only ever sees *TokenExpiredError if that refresh also fails. The new access/refresh token pair is persisted back into the session, replacing the old one — a refresh token is single-use and rotates on every real use, the old value stops working the moment a new one is issued.

*TokenExpiredError is still the error you check for, but its meaning is now "not signed in, full stop" rather than "the 15-minute access token died" — it's returned only once a refresh has already been attempted and failed too (or no refresh token was ever stored, e.g. a session from before this package version). In every one of those cases, check for it (errors.As) and send the user back through client.StartAuthorization() for a fresh login. The backend deliberately does not tell this package why a refresh failed — ordinary expiry, an already-rotated token being replayed (a real reuse/compromise signal), or the underlying session being revoked all produce the same generic rejection (RFC 9700 §4.14.2's own reasoning: the server can't tell which party presented the stale token) — so this package has nothing more specific to offer a caller than "not valid anymore."

A genuine transport/network failure reaching OneHux (during either the original /userinfo call or the refresh call itself) is deliberately NOT reported as *TokenExpiredError — see "A real bug fixed along the way" below. UserinfoHandler maps that case to 500, not a fabricated "session expired," and never clears the stored tokens, since they may still be perfectly valid.

If you call client.GetUserinfo() yourself outside of Handlers (see "Using the client directly" above), it never retries on your behalf — it's a pure API call with no session concept. Check for *TokenExpiredError, call client.RefreshAccessToken() yourself if you have a stored refresh token, persist the newly-rotated one, and retry once.

Public clients (a future mobile/desktop SDK, no ClientSecret) get tighter refresh-token settings than this package's confidential-client model (7-day idle timeout / 14-day absolute lifetime vs. 30/30 here) — not relevant to this package today, but worth knowing the number "30 days" isn't a platform-wide constant.

A real bug fixed along the way

Tracing the transient-failure/real-expiry distinction this feature needed to preserve surfaced a real, pre-existing bug: GetUserinfo() used to wrap ANY error from the underlying HTTP call — including a genuine network/transport failure (DNS, connection refused, timeout) — as *TokenExpiredError. A caller checking errors.As(err, &TokenExpiredError{}) could not tell "OneHux is unreachable right now" from "this token is genuinely dead." Fixed: a transport failure is now returned as a plain wrapped error, never *TokenExpiredError. See CHANGELOG.md for the full detail.

SessionStore — no interface change, no migration needed

If you've implemented your own SessionStore (Redis, a database, ...) instead of using MemorySessionStore: you don't need to change anything. This was traced deliberately before writing any code for this feature, precisely because SessionStore is a public interface a real integrator may have already implemented. Get/Save/Destroy already exchange an open map[string]stringHandlers, not SessionStore, owns which semantic keys it reads and writes (true for the PKCE state/verifier and the Back-Channel Logout sid long before this release). The refresh token is stored under one more key in that same map, entirely internal to handlers.go. See CHANGELOG.md for the full reasoning.

Example project

See example/ for a complete, runnable Go program using this module end-to-end — real sign-in, real /userinfo claims, real RP-initiated logout, and real OIDC Back-Channel Logout, all against production. It has its own go.mod with a replace directive pointing at this package.

cd example
ONEHUX_CLIENT_ID=... ONEHUX_CLIENT_SECRET=... ONEHUX_BACKCHANNEL_LOGOUT_SIGNING_SECRET=... go run .

Build

go build ./...
go vet ./...

License

Apache License 2.0 — see LICENSE.

Documentation

Overview

client.go OneHuxClient — PKCE generation, the hosted-login redirect, the authorization_code token exchange, /userinfo, and the RP-initiated /end-session redirect. Framework-agnostic: takes/ returns plain strings and structs, no dependency on net/http handler wiring — handlers.go wires this to a real net/http-based SessionStore. Zero non-stdlib dependencies, matching the same minimal-footprint discipline as the Node.js SDK (this package's closest sibling).

Two distinct hosts, by design (README.md ADR-070 in the backend repo, found by a real end-to-end manual walkthrough against production): the hosted login/logout pages live on LoginBaseURL, the actual OAuth API lives on the separate APIBaseURL. Mixing them up previously produced a silent 404 HTML body instead of a JSON error — this client never lets that ambiguity exist, since the two are separate struct fields, not one shared value.

errors.go Errors raised by the OneHux SSO client. Real backend error shapes (error/error_description) are preserved, not swallowed into a generic message — a caller that wants the raw OAuth error code can type-assert to *TokenExchangeError directly.

handlers.go Handlers — real, runnable net/http handlers wiring OneHuxClient to a real SessionStore, the same BFF discipline the platform's own dashboard follows on itself: the access token lives only server-side, never sent to the browser. Mirrors onehux_sso.views (Django), createOneHuxRouter (Node.js), and OneHuxSSOController (Laravel) in shape and behavior.

session.go SessionStore — the minimal server-side session abstraction Handlers (handlers.go) needs. Go has no single dominant session framework the way Django/Express/Laravel do, so this package defines the narrow interface it actually needs and ships one real, working implementation (MemorySessionStore) rather than assuming a specific one. A production deployment running more than one process should supply its own SessionStore backed by shared storage (Redis, a database, ...) — MemorySessionStore is correct for a single-process app only, the same caveat every in-memory index in this SDK family carries.

Index

Constants

View Source
const LogoutEventClaimKey = "http://schemas.openid.net/event/backchannel-logout"

LogoutEventClaimKey is the fixed key OIDC Back-Channel Logout requires inside a logout_token's `events` claim (spec §2.4).

Variables

This section is empty.

Functions

This section is empty.

Types

type ClientOptions

type ClientOptions struct {
	ClientID              string
	ClientSecret          string
	RedirectURI           string
	PostLogoutRedirectURI string
	LoginBaseURL          string
	APIBaseURL            string
	Scope                 string
	HTTPClient            *http.Client
}

ClientOptions configures a new OneHuxClient. LoginBaseURL/APIBaseURL/Scope default to this platform's own production hosts and OpenID's standard scope when left empty.

type Handlers

type Handlers struct {
	// contains filtered or unexported fields
}

Handlers bundles a OneHuxClient + SessionStore into five real, runnable http.HandlerFuncs.

func NewHandlers

func NewHandlers(opts HandlersOptions) *Handlers

NewHandlers constructs Handlers from opts, applying the same defaults documented on HandlersOptions.

func (*Handlers) BackchannelLogoutHandler

func (h *Handlers) BackchannelLogoutHandler(w http.ResponseWriter, r *http.Request)

BackchannelLogoutHandler: POST {prefix}/backchannel-logout — OIDC Back-Channel Logout receiving endpoint (spec: openid-connect-backchannel-1_0.html §2.6). Register this exact URL (e.g. https://yourapp.example.com/auth/backchannel-logout) via PATCH /api/v1/applications/{id}/backchannel-logout/ on OneHux, and set BackchannelLogoutSigningSecret to the secret returned by that call.

This is what makes an IdP-initiated logout (a user clicking "log out" directly on accounts.onehux.com, or an admin revoking their session) actually terminate THIS app's own local session in real time, rather than only being discovered the next time this app happens to call /userinfo and gets a 401. The platform-side session is genuinely revoked immediately either way — without this handler mounted and registered, only the notification is missing.

No CSRF protection is applied (and none should be) — this is a server-to-server POST from OneHux's own backend with no browser session/cookie of its own to carry a CSRF token; the logout_token's own HS256 signature (verified below) is this endpoint's real authenticity check. Per spec: responds 200 on success, 400 on any validation failure, always with Cache-Control: no-store.

func (*Handlers) CallbackHandler

func (h *Handlers) CallbackHandler(w http.ResponseWriter, r *http.Request)

CallbackHandler: GET {prefix}/callback — verifies state, exchanges the code, stores the access token in the session, redirects to LoginSuccessRedirect. Also indexes the session by the id_token's `sid` claim (OIDC Back-Channel Logout — optional feature, see BackchannelLogoutHandler).

On a step_up_required response specifically (README.md ADR-076, backend repo), this redirects the browser to complete step-up rather than failing — the pending PKCE state/verifier is deliberately NOT deleted from the session in that one case, since the browser will land back on this exact handler shortly with a brand-new code for the same state. Every other outcome (success, *InvalidStateError, any other *TokenExchangeError) deletes it exactly as before — this is a narrow, additive branch, not a change to the general discard behavior.

func (*Handlers) LoginHandler

func (h *Handlers) LoginHandler(w http.ResponseWriter, r *http.Request)

LoginHandler: GET {prefix}/login — starts the flow: generates PKCE + state, stashes them in the session, redirects to the real hosted login page.

func (*Handlers) LogoutHandler

func (h *Handlers) LogoutHandler(w http.ResponseWriter, r *http.Request)

LogoutHandler: GET {prefix}/logout — clears the local session access token, then redirects through the real RP-initiated /end-session flow, ending the platform-wide session, not just this app's own local one.

func (*Handlers) Mount

func (h *Handlers) Mount(mux *http.ServeMux, prefix string)

Mount registers all five routes on mux under prefix (e.g. "/auth"), giving you {prefix}/login, {prefix}/callback, {prefix}/logout, {prefix}/userinfo, and {prefix}/backchannel-logout.

func (*Handlers) UserinfoHandler

func (h *Handlers) UserinfoHandler(w http.ResponseWriter, r *http.Request)

UserinfoHandler: GET {prefix}/userinfo — a ready-to-use JSON endpoint for your own frontend to call, matching the BFF pattern documented for the web-frontend integration guide: your frontend calls your own backend, never OneHux directly.

On an expired access token, attempts exactly one silent refresh using the session's stored refresh token (backend repo README.md ADR-081) before falling back to a 401 — the caller only ever sees an error if that refresh also fails, or no refresh token was stored. Returns 401 with a real message when the session truly can't be renewed, so the caller knows to redirect through {prefix}/login again rather than retry.

type HandlersOptions

type HandlersOptions struct {
	Client *OneHuxClient
	Store  SessionStore

	// SessionAccessTokenKey is the SessionStore key the access token is stored under. Default:
	// "onehux_access_token".
	SessionAccessTokenKey string
	// LoginSuccessRedirect is where a successful login redirects to. Default: "/".
	LoginSuccessRedirect string
	// BackchannelLogoutSigningSecret is the dedicated secret shown once when you registered
	// your backchannel_logout_uri via PATCH /api/v1/applications/{id}/backchannel-logout/ —
	// deliberately NOT ClientSecret. Required only if you want BackchannelLogoutHandler to
	// actually verify and act on incoming logout_token POSTs; if empty, that handler responds
	// 400 to every request rather than silently accepting an unverifiable one.
	BackchannelLogoutSigningSecret string
	// SidIndex maps a OneHux Session id to a local session id (see SidIndex's own docstring).
	// Defaults to an in-memory index, correct for a single-process deployment only.
	SidIndex SidIndex
}

HandlersOptions configures Handlers. Only Client and Store are required.

type InvalidLogoutTokenError

type InvalidLogoutTokenError struct {
	Message string
}

InvalidLogoutTokenError: an incoming POST to the backchannel-logout handler failed real OIDC Back-Channel Logout validation (spec §2.6) — bad/missing signature, wrong aud, missing/ malformed events claim, a present nonce claim (forbidden), an expired token, or a missing sub/sid. The handler turns this into the spec-required HTTP 400, never a 500 — a forged or malformed request on a public endpoint is expected adversarial input, not a server bug.

func (*InvalidLogoutTokenError) Error

func (e *InvalidLogoutTokenError) Error() string

type InvalidStateError

type InvalidStateError struct {
	Message string
}

InvalidStateError: the callback's state parameter didn't match what was stashed at redirect time, or code/state was missing outright — a real CSRF-protection failure, or a stale/ replayed callback URL.

func (*InvalidStateError) Error

func (e *InvalidStateError) Error() string

type LogoutTokenPayload

type LogoutTokenPayload struct {
	Issuer   string                 `json:"iss"`
	Audience string                 `json:"aud"`
	IssuedAt int64                  `json:"iat"`
	Expiry   int64                  `json:"exp"`
	JTI      string                 `json:"jti"`
	Events   map[string]interface{} `json:"events"`
	Subject  string                 `json:"sub,omitempty"`
	SID      string                 `json:"sid,omitempty"`
}

LogoutTokenPayload is a verified OIDC Back-Channel Logout logout_token's claims.

type MemorySessionStore

type MemorySessionStore struct {
	// contains filtered or unexported fields
}

MemorySessionStore is a real, working, in-process SessionStore — correct for the example app and any genuinely single-process deployment. Session ids are cryptographically random and carried in an HttpOnly cookie; nothing but the opaque id ever reaches the browser.

func NewMemorySessionStore

func NewMemorySessionStore(secure bool) *MemorySessionStore

NewMemorySessionStore constructs an empty store. Set secure=true in production (HTTPS-only cookie) — false is appropriate for local http://localhost development, matching the other SDKs' own example apps.

func (*MemorySessionStore) Destroy

func (s *MemorySessionStore) Destroy(id string) error

func (*MemorySessionStore) Get

func (*MemorySessionStore) Save

func (s *MemorySessionStore) Save(id string, values map[string]string) error

type MemorySidIndex

type MemorySidIndex struct {
	// contains filtered or unexported fields
}

MemorySidIndex is a real, working, in-process SidIndex — correct for the example app and any genuinely single-process deployment.

func NewMemorySidIndex

func NewMemorySidIndex() *MemorySidIndex

NewMemorySidIndex constructs an empty index.

func (*MemorySidIndex) Delete

func (idx *MemorySidIndex) Delete(sid string)

func (*MemorySidIndex) Get

func (idx *MemorySidIndex) Get(sid string) (string, bool)

func (*MemorySidIndex) Set

func (idx *MemorySidIndex) Set(sid, sessionID string)

type OneHuxClient

type OneHuxClient struct {
	ClientID              string
	ClientSecret          string
	RedirectURI           string
	PostLogoutRedirectURI string
	LoginBaseURL          string
	APIBaseURL            string
	Scope                 string
	// contains filtered or unexported fields
}

OneHuxClient is a confidential OAuth 2.0 + PKCE client for OneHux Accounts.

func NewClient

func NewClient(opts ClientOptions) *OneHuxClient

NewClient constructs a OneHuxClient, applying the same production-host/scope defaults every other SDK in this family uses.

func (*OneHuxClient) BuildLogoutURL

func (c *OneHuxClient) BuildLogoutURL(state string) string

BuildLogoutURL builds the RP-initiated logout redirect (README.md ADR-070, backend repo): PostLogoutRedirectURI must already be registered in this Application's own redirect_uris list — the same list the login callback uses, not a separate one — or the platform rejects this with a real 400.

func (*OneHuxClient) BuildStepUpRedirectURL

func (c *OneHuxClient) BuildStepUpRedirectURL(codeVerifier, state string) string

BuildStepUpRedirectURL builds the redirect used when ExchangeCode returns *StepUpRequiredError (README.md ADR-076, backend repo). Reuses this SAME pending authorization's ClientID/RedirectURI/Scope/state, and re-derives code_challenge from the already-stored codeVerifier (PKCE code_challenge is a pure function of code_verifier, so nothing extra needs to be persisted). Deep-links straight to the real hosted email-OTP step-up page — the exact same URL the platform's own first-party dashboard redirects to for this identical error (backend repo: frontend/src/lib/server/step-up.ts) — rather than the generic /login page, since the platform requires a step-up-caliber method specifically here. The caller MUST NOT discard the pending codeVerifier/state before calling this: the browser will land back on this same app's callback shortly with a brand-new code for this same state, and CallbackHandler needs the still-stored codeVerifier to exchange it.

func (*OneHuxClient) ExchangeCode

func (c *OneHuxClient) ExchangeCode(code, state, expectedState, codeVerifier string) (*TokenResult, error)

ExchangeCode verifies state, then exchanges code for real tokens via POST {APIBaseURL}/api/v1/oauth/token/. Returns *InvalidStateError on a state mismatch/missing code (never attempts the exchange in that case), and *TokenExchangeError carrying the real OAuth error/error_description on a non-2xx response.

func (*OneHuxClient) ExtractSidFromIDToken

func (c *OneHuxClient) ExtractSidFromIDToken(idToken string) string

ExtractSidFromIDToken pulls the `sid` claim out of idToken WITHOUT verifying its signature — this package has no way to verify an id_token's signature (OneHux Accounts signs it with a server-only key never shared with any client — the backend repo's oauth.services.build_jwt() docstring flags this as a deliberate Phase-1 gap), and doesn't need to for this purpose: the token was retrieved directly from a client_secret-authenticated POST to /api/v1/oauth/token/ over TLS, not an untrusted redirect parameter, so trusting its contents here (indexing a local session for a later logout_token match) is standard OIDC RP practice. Returns "" if the token doesn't decode or has no sid claim.

func (*OneHuxClient) GetPublicApplications

func (c *OneHuxClient) GetPublicApplications(orgSlug string) ([]PublicApplication, error)

GetPublicApplications calls GET {APIBaseURL}/api/v1/organizations/{orgSlug}/ public-applications/ — the platform's public, unauthenticated application-launcher endpoint (README.md ADR-078). No ClientID/ClientSecret involved: this is a public, unauthenticated GET, usable for any Organization by its own slug, not just this client's own configured one. Returns *OrganizationNotFoundError if orgSlug doesn't match a usable Organization.

func (*OneHuxClient) GetUserinfo

func (c *OneHuxClient) GetUserinfo(accessToken string) (map[string]interface{}, error)

GetUserinfo calls GET {APIBaseURL}/api/v1/oauth/userinfo/ — real claims (sub, name, email, picture, roles, permissions, ...), recomputed live by the backend on every call, never cached here. Returns *TokenExpiredError on any non-2xx response: OneHux Accounts access tokens are a 15-minute, single-issue lifetime. This method itself never retries — it's a pure API call with no session concept (see this file's own header comment). A caller holding a refresh token should check for *TokenExpiredError, call RefreshAccessToken(), and retry this call once with the new access token — Handlers.UserinfoHandler already does exactly that automatically; a caller using OneHuxClient directly must do it itself.

A genuine transport/network failure making the request (DNS, connection refused, timeout, ...) is NOT wrapped as *TokenExpiredError — fixed here: it previously was, which meant a transient outage reaching OneHux looked identical to a genuinely dead/revoked token to any caller checking errors.As(err, &TokenExpiredError{}). It's now returned as-is (wrapped with %w), matching the distinction this package's own error types already exist to preserve elsewhere (*TokenExchangeError vs. a plain wrapped error, for example).

func (*OneHuxClient) RefreshAccessToken added in v0.2.0

func (c *OneHuxClient) RefreshAccessToken(refreshToken string) (*TokenResult, error)

RefreshAccessToken rotates refreshToken for a fresh access/id/refresh token triple via POST {APIBaseURL}/api/v1/oauth/token/ (grant_type=refresh_token — backend repo README.md ADR-081). The presented refresh token is invalidated by this call whether it succeeds or fails to be usable again — the backend's own rotation-with-reuse-detection means a refresh token is single-use; the caller MUST persist the newly-returned RefreshToken and discard the one just presented, never retry this same call with the old value.

Returns *TokenExpiredError on any non-2xx response — a rejected refresh token means the family expired, was rotated away already (reuse), or the underlying Session was revoked (logout, Back-Channel Logout, admin action). In every one of those cases the correct remedy is the same: send the user through OneHuxClient.StartAuthorization() again. This client deliberately does not distinguish *why* the refresh failed — the backend itself returns the same generic invalid_grant for an ordinary expiry and a detected compromise (RFC 9700 §4.14.2's own reasoning: it can't tell which party presented the stale token), so this client has no more information to offer a caller than "not valid anymore." A genuine transport/network failure making the request is NOT wrapped as *TokenExpiredError — it's returned as-is (wrapped with %w), so a caller can distinguish "the refresh token is dead" from "OneHux was unreachable" (see GetUserinfo's own docstring for why this distinction matters and was previously missing from this package).

func (*OneHuxClient) StartAuthorization

func (c *OneHuxClient) StartAuthorization() (*PendingAuthorization, error)

StartAuthorization generates a fresh PKCE pair + state and builds the hosted-login redirect URL. The caller is responsible for persisting CodeVerifier/State server-side (a real session) until the callback — never in a cookie the browser itself can read.

func (*OneHuxClient) VerifyLogoutToken

func (c *OneHuxClient) VerifyLogoutToken(logoutToken, signingSecret string) (*LogoutTokenPayload, error)

VerifyLogoutToken performs real OIDC Back-Channel Logout validation (spec §2.6), HS256- verified by hand via crypto/hmac — this package has zero non-stdlib dependencies and a full JWT library isn't worth pulling in for one HMAC check. signingSecret is the dedicated secret shown once when you registered your backchannel_logout_uri via PATCH /api/v1/applications/{id}/backchannel-logout/ — deliberately NOT this client's own ClientSecret (the backend cannot read that back to sign anything with it; see the backend repo's README.md ADR-074). Returns *InvalidLogoutTokenError on any validation failure.

type OrganizationNotFoundError

type OrganizationNotFoundError struct {
	ErrorDescription string
}

OrganizationNotFoundError: GET /api/v1/organizations/{orgSlug}/public-applications/ returned a non-2xx response — no Organization matches that slug, or it isn't usable (deactivated/ deleted). Carries the real error description from the backend rather than a generic message.

func (*OrganizationNotFoundError) Error

func (e *OrganizationNotFoundError) Error() string

type PendingAuthorization

type PendingAuthorization struct {
	CodeVerifier     string
	State            string
	AuthorizationURL string
}

PendingAuthorization is the PKCE verifier + state a caller must persist (a real server-side session) between the redirect and the callback — never round-tripped through browser-visible state.

type PublicApplication

type PublicApplication struct {
	Name    string
	LogoURL string
	HomeURL string
}

PublicApplication is one entry from GET {APIBaseURL}/api/v1/organizations/{orgSlug}/ public-applications/ — deliberately only Name/LogoURL/HomeURL, matching exactly what that endpoint returns. No ClientID, no slug, no OAuth-relevant identifier: a pure "what can I launch" list, not a way to start a sign-in flow.

type SessionStore

type SessionStore interface {
	// Get resolves the caller's session from r (via its cookie), creating a new empty one
	// (and queuing its cookie on w) if none exists yet. Returns the session id and its current
	// values.
	Get(w http.ResponseWriter, r *http.Request) (id string, values map[string]string, err error)
	// Save persists values under the given session id.
	Save(id string, values map[string]string) error
	// Destroy deletes the session identified by id outright, wherever it's stored — used both
	// by the local /logout handler and, more importantly, by OIDC Back-Channel Logout, which
	// destroys a session that has nothing to do with the current request at all.
	Destroy(id string) error
}

SessionStore is the server-side session contract Handlers relies on. Values are plain string->string maps — enough for what this package itself stores (an access token, an id_token's sid) and for a caller's own additional session data.

type SidIndex

type SidIndex interface {
	Set(sid, sessionID string)
	Get(sid string) (sessionID string, ok bool)
	Delete(sid string)
}

SidIndex maps a OneHux Session id (`sid`) to the local session id it's associated with — the bridge a real logout_token POST (which only carries `sid`) needs in order to find and destroy the matching local SessionStore entry. Same shape/caveat as the Node.js SDK's own BackchannelSidIndex: the default MemorySidIndex only works within a single process; a real multi-process deployment must supply a shared implementation.

type StepUpRequiredError

type StepUpRequiredError struct {
	ErrorDescription string
}

StepUpRequiredError: POST /api/v1/oauth/token/ returned {"error": "step_up_required", ...} (README.md ADR-076, backend repo) — credentials/code were valid, but the platform's device/location trust gate rejected this specific login (password or Google) as coming from an unrecognized device/location. NOT a fatal error: ExchangeCode returns this distinctly from *TokenExchangeError so the caller (CallbackHandler) can redirect the browser to complete step-up (magic link/email code/passkey) rather than showing a hard failure — the same automatic-redirect behavior the platform's own first-party dashboard uses for this identical error.

func (*StepUpRequiredError) Error

func (e *StepUpRequiredError) Error() string

type TokenExchangeError

type TokenExchangeError struct {
	OAuthError       string
	ErrorDescription string
	StatusCode       int
}

TokenExchangeError: POST /api/v1/oauth/token/ returned a non-2xx response. Carries the real OAuth error/error_description from oauth.views._error_response() rather than a generic message, so a caller can distinguish e.g. invalid_grant (expired code) from invalid_client (misconfigured client_id/secret).

func (*TokenExchangeError) Error

func (e *TokenExchangeError) Error() string

type TokenExpiredError

type TokenExpiredError struct {
	Message string
}

TokenExpiredError: GET /api/v1/oauth/userinfo/ rejected the access token, OR OneHuxClient.RefreshAccessToken() had its refresh token rejected.

OneHux Accounts access tokens are a 15-minute lifetime; refresh tokens (backend repo README.md ADR-081) let a caller renew one without a full re-login, but are themselves single-use and eventually expire too. This error means whichever credential was presented — access token or refresh token — is no longer valid, for any reason: ordinary expiry, already-rotated-away reuse, or the underlying session being revoked (logout, Back-Channel Logout, admin action). The backend deliberately does not distinguish these to the caller (RFC 9700 §4.14.2), so neither does this error. Callers must route the user back through OneHuxClient.StartAuthorization() for a fresh login — Handlers.UserinfoHandler already attempts one silent refresh-and-retry first when a refresh token is stored; this error means that also failed (or no refresh token was available to try).

func (*TokenExpiredError) Error

func (e *TokenExpiredError) Error() string

type TokenResult

type TokenResult struct {
	AccessToken  string
	IDToken      string
	RefreshToken string
	TokenType    string
	ExpiresIn    int
	Scope        string
}

TokenResult mirrors oauth.views.TokenView's real response shape exactly (access_token, id_token, refresh_token, token_type, expires_in, scope) — no fields invented, none dropped.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL