Documentation
¶
Overview ¶
Package auth implements GitHub OAuth + session cookies + an allowlist gate so stackit-server can be safely exposed on the public web.
The package is internal to the api layer: handlers, middleware, and cryptographic helpers live here together so the surface a consumer composes is just (Config, Store, Handler, Allowlist, RequireSession).
Index ¶
- Constants
- Variables
- func GenerateSessionKey() (string, error)
- func LoginExpiryProbe(now time.Time) time.Time
- func RequireCSRFHeader(next http.Handler) http.Handler
- func RequireSession(store Store, next http.Handler) http.Handler
- type Allowlist
- type AllowlistConfig
- type Cipher
- type Config
- type CookieOptions
- type Handler
- func (h *Handler) CallbackHandler(w http.ResponseWriter, r *http.Request)
- func (h *Handler) LoginHandler(w http.ResponseWriter, r *http.Request)
- func (h *Handler) LogoutHandler(w http.ResponseWriter, r *http.Request)
- func (h *Handler) MeHandler(w http.ResponseWriter, r *http.Request)
- func (h *Handler) SetGitHubEndpointForTest(endpoint oauth2.Endpoint, userInfoURL string, client *http.Client)
- type MeResponse
- type MemoryStore
- type Session
- type Store
Constants ¶
const CSRFHeader = "X-Stackit-CSRF"
CSRFHeader is the custom request header the web client adds on every non-safe request. Its presence — not value — is what matters: browsers can't set a custom header on a cross-origin request without a CORS preflight, and the server's CORS allowlist is exact-match. That means only configured origins can satisfy this check, which is enough to block cookie-based CSRF without a per-request token.
const SessionKeyBytes = 32
SessionKeyBytes is the required key length for the AES-GCM cipher.
const SessionTTL = 30 * 24 * time.Hour
SessionTTL is how long a session lives from creation. Sessions don't slide — once expired the user re-authenticates. 30 days matches the default GH PAT-style cadence we'd want for a dev tool.
Variables ¶
var DefaultScopes = []string{"read:user", "repo"}
DefaultScopes is what the OAuth flow requests today. `read:user` gets the identity needed for the allowlist check; `repo` captures push scope up front so when per-user push lands later the user doesn't re-consent.
var ErrDenied = errors.New("user not in allowlist")
ErrDenied is returned by Allowlist.Check when the logged-in user is not permitted. Handlers translate this into a redirect to /auth/denied.
Functions ¶
func GenerateSessionKey ¶
GenerateSessionKey returns a fresh base64-encoded key in the format the server expects. The operator runs `openssl rand -base64 32` in docs/deploy.md; this is the in-Go equivalent used by tests.
func LoginExpiryProbe ¶
LoginExpiryProbe is the time after which a session would be considered stale. Exposed for tests / health-check style code.
func RequireCSRFHeader ¶
RequireCSRFHeader gates non-safe methods on the presence of a custom CSRFHeader. Safe methods pass through untouched. Failures emit a 403 JSON response so clients can distinguish "your auth is bad" (401) from "your call shape is wrong" (403).
Pair this with RequireSession on /api/*: RequireSession says "you must be logged in", RequireCSRFHeader says "and this isn't a cross-site drive-by".
func RequireSession ¶
RequireSession returns a middleware that ensures the request carries a valid session cookie before forwarding to next. Failed checks emit 401 JSON; passed checks attach the Session to the request context for downstream handlers to read via SessionFromContext.
This middleware does *not* set cookie attributes or talk to GitHub — it's a pure store lookup. Sessions are created elsewhere (the OAuth callback) and revoked elsewhere (logout, TTL sweep).
Types ¶
type Allowlist ¶
type Allowlist struct {
// contains filtered or unexported fields
}
Allowlist is the gate the OAuth callback runs against after exchanging the GitHub code for an identity. It supports two independent rules:
- Users: exact-match GitHub logins (case-insensitive)
- Org: membership in a GitHub organization (verified via API)
At least one rule must be configured; otherwise the gate is open and the constructor refuses to build.
func NewAllowlist ¶
func NewAllowlist(cfg AllowlistConfig) (*Allowlist, error)
NewAllowlist normalizes the config and validates at least one rule is present. An empty allowlist is rejected because a public-facing deploy with no gate is exactly the misconfiguration we're trying to prevent.
func (*Allowlist) Check ¶
Check returns nil if login is allowed, ErrDenied if not. The token is only used for the org membership API call; user-list hits short-circuit without any network I/O.
func (*Allowlist) SetOrgChecker ¶
func (a *Allowlist) SetOrgChecker(c orgMembershipChecker)
SetOrgChecker overrides the org membership checker. Tests use this to inject deterministic membership without hitting GitHub.
type AllowlistConfig ¶
AllowlistConfig is the operator-supplied input. ConfigFromEnv reads these out of process env; callers in tests build the struct directly.
type Cipher ¶
type Cipher struct {
// contains filtered or unexported fields
}
Cipher AES-GCM-encrypts short blobs (currently the user's GitHub access token) before they sit in the in-memory session store. The key comes from STACKIT_SESSION_KEY; rotation requires a process restart, which also invalidates every existing session — that's fine for the in-memory store.
func NewCipher ¶
NewCipher decodes a base64-encoded 32-byte key and returns a ready AES-256-GCM cipher.
type Config ¶
type Config struct {
ClientID string
ClientSecret string //nolint:gosec // field name reflects the OAuth spec; not a literal secret
BaseURL string
Scopes []string
// AfterCallbackPath is where the user lands after a successful login.
// Defaults to "/". Settable for tests.
AfterCallbackPath string
// DeniedPath is where allowlist denials redirect to. Defaults to
// "/auth/denied". The static handler serves a small page at this path;
// if the frontend doesn't render one, the browser sees a generic 404
// from the SPA which is still a clear signal.
DeniedPath string
// Cookies controls Secure-flag behavior.
Cookies CookieOptions
}
Config is the operator-supplied OAuth configuration. ClientID/Secret are the values from the GitHub OAuth App. BaseURL is the canonical https:// URL the server is reachable at (used to build the callback URL because the server cannot infer its public hostname).
type CookieOptions ¶
type CookieOptions struct {
Secure bool
}
CookieOptions controls the attributes the auth package puts on cookies. Secure should be true in production (we're behind HTTPS at the proxy), and false in tests or local dev where the browser refuses Secure cookies on http://localhost.
type Handler ¶
type Handler struct {
// contains filtered or unexported fields
}
Handler bundles the OAuth state needed by the login/callback/logout/me HTTP handlers. Build one in main.go; mount its methods at the auth routes.
func NewHandler ¶
NewHandler validates the config and returns a ready Handler.
func (*Handler) CallbackHandler ¶
func (h *Handler) CallbackHandler(w http.ResponseWriter, r *http.Request)
CallbackHandler completes the OAuth flow: verifies state, exchanges the code for a token, fetches the GitHub user, checks the allowlist, creates a session, and redirects the user to the app (or to DeniedPath on a non-allowlisted login).
func (*Handler) LoginHandler ¶
func (h *Handler) LoginHandler(w http.ResponseWriter, r *http.Request)
LoginHandler kicks off the OAuth flow. It mints a fresh `state` value, stores it in a short-lived cookie, optionally remembers the desired post-login URL via ?return=, and 302s the user to GitHub.
func (*Handler) LogoutHandler ¶
func (h *Handler) LogoutHandler(w http.ResponseWriter, r *http.Request)
LogoutHandler drops the session both server-side (Store.Delete) and client-side (clears the cookie). Always returns 204 so the web app can fire-and-forget the call.
func (*Handler) MeHandler ¶
func (h *Handler) MeHandler(w http.ResponseWriter, r *http.Request)
MeHandler returns the logged-in user's identity, or 401 if no session. The web app calls this on load to decide between rendering the app and redirecting to /auth/login.
func (*Handler) SetGitHubEndpointForTest ¶
func (h *Handler) SetGitHubEndpointForTest(endpoint oauth2.Endpoint, userInfoURL string, client *http.Client)
SetGitHubEndpointForTest swaps the OAuth provider endpoint and HTTP client. Tests point both at an httptest.Server stubbing GitHub's auth + API surface.
type MeResponse ¶
MeResponse is the body served by GET /auth/me.
type MemoryStore ¶
type MemoryStore struct {
// contains filtered or unexported fields
}
MemoryStore keeps sessions in a process-local map. Restarting the server drops every session — that's the documented behavior in docs/deploy.md. If you ever scale this horizontally, swap to a SQLite/Redis-backed Store behind the same interface.
func NewMemoryStore ¶
func NewMemoryStore(c *Cipher) *MemoryStore
NewMemoryStore returns a Store backed by a map. The background sweeper goroutine is started immediately; Close() stops it.
func (*MemoryStore) Close ¶
func (s *MemoryStore) Close() error
Close stops the sweeper. Safe to call multiple times.
func (*MemoryStore) Create ¶
func (s *MemoryStore) Create(login string, githubUserID int64, accessToken string) (*Session, error)
Create allocates an opaque 32-byte session ID, encrypts the supplied GitHub token, and stores the result. The caller sets a cookie with the returned Session.ID.
func (*MemoryStore) Delete ¶
func (s *MemoryStore) Delete(id string)
Delete is a no-op for unknown IDs.
func (*MemoryStore) Get ¶
func (s *MemoryStore) Get(id string) (*Session, bool)
Get returns the session if it exists and hasn't expired. Expired sessions are evicted lazily on read in addition to the periodic sweep, so a long gap between sweeps can't keep a session alive past its ExpiresAt.
func (*MemoryStore) SetClockForTest ¶
func (s *MemoryStore) SetClockForTest(now func() time.Time)
SetClockForTest swaps the time source. Tests use this to advance past ExpiresAt without sleeping.
type Session ¶
type Session struct {
ID string
GitHubLogin string
GitHubUserID int64
CreatedAt time.Time
ExpiresAt time.Time
// contains filtered or unexported fields
}
Session is the per-user record the store holds. The GitHub access token is encrypted at rest with the server's Cipher; everything else is plaintext because it's needed for routing/auditing and isn't sensitive on its own.
func SessionFromContext ¶
SessionFromContext returns the session attached by RequireSession, or nil if no session is in scope. Handlers behind RequireSession can assume non-nil; unprotected handlers must nil-check.
type Store ¶
type Store interface {
Create(login string, githubUserID int64, accessToken string) (*Session, error)
Get(id string) (*Session, bool)
Delete(id string)
Close() error
}
Store is the surface used by handlers. The default implementation is MemoryStore; tests inject their own fakes against the same interface.