auth

package
v0.22.0 Latest Latest
Warning

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

Go to latest
Published: Jul 20, 2026 License: MIT Imports: 18 Imported by: 0

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

View Source
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.

View Source
const SessionKeyBytes = 32

SessionKeyBytes is the required key length for the AES-GCM cipher.

View Source
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

View Source
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.

View Source
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

func GenerateSessionKey() (string, error)

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

func LoginExpiryProbe(now time.Time) time.Time

LoginExpiryProbe is the time after which a session would be considered stale. Exposed for tests / health-check style code.

func RequireCSRFHeader

func RequireCSRFHeader(next http.Handler) http.Handler

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

func RequireSession(store Store, next http.Handler) http.Handler

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

func (a *Allowlist) Check(ctx context.Context, login, accessToken string) error

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

type AllowlistConfig struct {
	Users []string
	Org   string
}

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

func NewCipher(base64Key string) (*Cipher, error)

NewCipher decodes a base64-encoded 32-byte key and returns a ready AES-256-GCM cipher.

func (*Cipher) Open

func (c *Cipher) Open(blob []byte) ([]byte, error)

Open reverses Seal. It rejects anything shorter than a nonce and surfaces AEAD verification failures verbatim.

func (*Cipher) Seal

func (c *Cipher) Seal(plaintext []byte) ([]byte, error)

Seal encrypts plaintext with a fresh random nonce. The returned slice is nonce || ciphertext+tag; the caller stores it as-is.

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

func NewHandler(cfg Config, store Store, allow *Allowlist, cipher *Cipher) (*Handler, error)

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

type MeResponse struct {
	Login string `json:"login"`
	ID    int64  `json:"id"`
}

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

func SessionFromContext(ctx context.Context) *Session

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.

func (*Session) AccessToken

func (s *Session) AccessToken(c *Cipher) (string, error)

AccessToken decrypts the stored GitHub token for an outbound API call. Callers should not retain the result longer than the one request that needs it — re-decrypt each time so the plaintext lives on the stack.

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.

Jump to

Keyboard shortcuts

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