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
- type ClientOptions
- type Handlers
- func (h *Handlers) BackchannelLogoutHandler(w http.ResponseWriter, r *http.Request)
- func (h *Handlers) CallbackHandler(w http.ResponseWriter, r *http.Request)
- func (h *Handlers) LoginHandler(w http.ResponseWriter, r *http.Request)
- func (h *Handlers) LogoutHandler(w http.ResponseWriter, r *http.Request)
- func (h *Handlers) Mount(mux *http.ServeMux, prefix string)
- func (h *Handlers) UserinfoHandler(w http.ResponseWriter, r *http.Request)
- type HandlersOptions
- type InvalidLogoutTokenError
- type InvalidStateError
- type LogoutTokenPayload
- type MemorySessionStore
- type MemorySidIndex
- type OneHuxClient
- func (c *OneHuxClient) BuildLogoutURL(state string) string
- func (c *OneHuxClient) BuildStepUpRedirectURL(codeVerifier, state string) string
- func (c *OneHuxClient) ExchangeCode(code, state, expectedState, codeVerifier string) (*TokenResult, error)
- func (c *OneHuxClient) ExtractSidFromIDToken(idToken string) string
- func (c *OneHuxClient) GetPublicApplications(orgSlug string) ([]PublicApplication, error)
- func (c *OneHuxClient) GetUserinfo(accessToken string) (map[string]interface{}, error)
- func (c *OneHuxClient) RefreshAccessToken(refreshToken string) (*TokenResult, error)
- func (c *OneHuxClient) StartAuthorization() (*PendingAuthorization, error)
- func (c *OneHuxClient) VerifyLogoutToken(logoutToken, signingSecret string) (*LogoutTokenPayload, error)
- type OrganizationNotFoundError
- type PendingAuthorization
- type PublicApplication
- type SessionStore
- type SidIndex
- type StepUpRequiredError
- type TokenExchangeError
- type TokenExpiredError
- type TokenResult
Constants ¶
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 ¶
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
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) 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 ¶
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 ¶
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 ¶
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.