Unverified Commit b508fccc authored by Minseong Choi's avatar Minseong Choi 💬
Browse files

feat(api): add felis-api service with permissions, modpack lane, and fleet read

The dual-faced felis-api: internal (service) and external (public/app/admin) routes behind a Zero-Trust guard. Includes the access domain (whitelist, ban, and LuckPerms permission/group control over the owner-gated RCON path), the modpack submission endpoints, and the admin-tier SysAdmin fleet read. Structured access fields are charset-validated before assembly so no field can splice a second RCON command.
parent d39605e0
Loading
Loading
Loading
Loading

internal/api/api.go

0 → 100644
+387 −0
Changes for internal/api/api.go: 387 added lines, 0 removed lines.
Original line number Diff line number Diff line
// Package api implements felis-api: one binary serving two faces (spec §7).
//
// The internal face (velocity / backend callbacks) authenticates with a static
// service token and is never wrapped in Zero Trust. The external face (people /
// panel) authenticates with a Cloudflare Access JWT; admin-tier operations
// additionally require the admin Access path (spec §14, graded by operation).
//
// Handlers depend on the Repo and Cluster interfaces, so the request routing,
// dual-face auth, input validation and authorization are all unit-tested with
// in-memory fakes. The Postgres (pgRepo) and controller-runtime (k8sCluster)
// implementations compile here but are exercised only by integration tests
// against a live database / cluster.
package api

import (
	"context"
	"net/http"
	"sync"
	"time"

	"felis.lolicon.best/internal/apis/felis/v1alpha1"
)

// API holds the dependencies shared by every handler.
type API struct {
	Repo     Repo
	Cluster  Cluster
	Internal InternalAuth
	External ExternalAuth

	// Builder is the image build subsystem (spec §16). It is optional: when nil
	// the /images routes report 503 rather than 404, so the admin boundary is
	// still exercised even before the subsystem is wired in.
	Builder ImageBuilder

	// Console is the synchronous RCON write channel (spec §8 写=RCON). It is
	// wired in production (cmd/felis); a nil Console makes the command route report
	// 503 rather than panic, so the ownership boundary is still exercised in tests.
	Console Console

	// Logs is the read-side console channel (spec §8 读=pods/log follow). Like
	// Console it is wired in production (cmd/felis); a nil Logs makes the console
	// route report 503 rather than panic, so the ownership boundary is still
	// exercised in tests.
	Logs LogStreamer

	// BuildLogs is the read-side build-log channel (spec §16, §416 日志流复用 §8):
	// it streams the build Job's Pod log (kaniko) in the build namespace, distinct
	// from Logs which streams a server pod in the minecraft namespace. Like Logs it
	// is wired in production (cmd/felis); a nil BuildLogs makes the admin-only
	// build-logs route report 503 rather than panic, so the admin boundary is still
	// exercised in tests.
	BuildLogs LogStreamer

	// Restorer starts a world restore from a backup (spec §466). It is optional:
	// when nil the restore-backup route reports 503, so the restore authorization
	// boundary is exercised before the restore-Job executor is wired. The list and
	// authorization paths do not need it (they read the Repo), only the kick-off.
	Restorer Restorer

	// Submissions is the user-modpack approval lane (a user-directed extension over
	// the §16 build subsystem; see internal/submit). It is optional: when
	// nil the /me/submissions and /submissions routes report 503 rather than 404, so
	// the app/admin boundary is still exercised even before the subsystem is wired
	// in. An ordinary user may only SUBMIT here; an admin approves before any build
	// runs, distinct from Builder which an admin drives directly.
	Submissions SubmissionService

	// RootDomain is injected from config (spec §2). It is the only place the
	// deployment zone enters the API; hostnames are validated against it and
	// never hardcoded.
	RootDomain string

	// WakeCooldown throttles repeated wakes per server (spec §9.1: cooldown hangs
	// on the wake lever). Zero disables throttling.
	WakeCooldown time.Duration

	// MaxRunningServers caps how many servers may be desired-Running cluster-wide
	// (spec §9.1: the concurrency-上限 lever hanging on the same wake chokepoint as
	// cooldown and autostartPolicy). Zero — the default — disables it: §9.2 wires
	// only autostartPolicy + cooldown as active wake gates, so this lever ships
	// inert, exactly like a zero WakeCooldown, and a deployment opts in by setting
	// a positive value. Enforced via withinRunningCap on the wake path.
	MaxRunningServers int

	// Now is the clock, injectable for tests. Defaults to time.Now.
	Now func() time.Time

	cooldownOnce sync.Once
	cooldown     *cooldownLimiter
}

// now returns the current time using the injected clock.
func (a *API) now() time.Time {
	if a.Now != nil {
		return a.Now()
	}
	return time.Now()
}

// limiter lazily builds the wake cooldown limiter bound to a's clock.
func (a *API) limiter() *cooldownLimiter {
	a.cooldownOnce.Do(func() {
		a.cooldown = &cooldownLimiter{now: a.now, last: map[string]time.Time{}}
	})
	return a.cooldown
}

// apiRoute is one served HTTP route. Each face exposes its routes as a single
// table (internalAPIRoutes / externalAPIRoutes) so that one declaration drives
// BOTH handler construction here AND the OpenAPI parity test (openapi_test.go):
// the doc is checked against the routes actually served — in both directions —
// rather than against a re-derivation, which a http.ServeMux cannot be asked to
// confirm (it exposes no way to enumerate its registered patterns).
type apiRoute struct {
	Method  string
	Pattern string

	// Public routes are unauthenticated and mounted on the outer mux — the health
	// probes, since kubelet / Cloudflare hold no token. Everything else is mounted
	// behind the face's auth middleware.
	Public bool

	// Admin marks an external-face route that is additionally gated on the admin
	// Zero-Trust path (the adminOnly wrapper + Principal.IsAdmin() inside the
	// handler). Internal-face routes never set it.
	Admin bool

	h http.HandlerFunc
}

// internalAPIRoutes is the internal face's served route table (spec §7, §14):
// service-token auth, never Zero Trust. It carries both health probes.
func (a *API) internalAPIRoutes() []apiRoute {
	return []apiRoute{
		{Method: "GET", Pattern: "/healthz", Public: true, h: a.handleHealthz},
		{Method: "GET", Pattern: "/readyz", Public: true, h: a.handleReadyz},

		{Method: "GET", Pattern: "/api/v1/servers", h: a.handleListServers},
		{Method: "GET", Pattern: "/api/v1/servers/by-host/{host}", h: a.handleByHost},
		{Method: "POST", Pattern: "/api/v1/internal/servers/{name}/ready", h: a.handleReady},
		{Method: "POST", Pattern: "/api/v1/internal/servers/{name}/join-event", h: a.handleJoinEvent},
		// Domain-autostart (spec §9.1, §14): velocity drives the wake lever and polls
		// status with its service token, identifying the joining player by online-mode
		// UUID. These live on the internal face because velocity holds no web Principal;
		// the external face keeps its own Principal-gated wake/status for the panel.
		{Method: "POST", Pattern: "/api/v1/internal/servers/{name}/wake", h: a.handleInternalWake},
		{Method: "GET", Pattern: "/api/v1/internal/servers/{name}/status", h: a.handleStatus},
		// Lobby `/menu` (spec §12): the felis-paper lobby is a pure UI face holding no
		// token, so velocity drives these on its behalf — claim by online-mode UUID
		// (the lobby's `Claim & Start`, separate from the autostartPolicy-gated wake)
		// and the menu projection that adds the ownership-derived `claimable` the §11
		// list/status views never carry.
		{Method: "POST", Pattern: "/api/v1/internal/servers/{name}/claim", h: a.handleInternalClaim},
		{Method: "GET", Pattern: "/api/v1/internal/servers/{name}/menu", h: a.handleInternalMenuStatus},
		// Account linking (spec §10): the in-game /link side mints a one-time code for a
		// verified UUID. Internal-only — the code is born from an online-mode UUID the
		// web never holds (account_link_codes has no user_id column).
		{Method: "POST", Pattern: "/api/v1/internal/account/link/code", h: a.handleCreateLinkCode},
	}
}

// externalAPIRoutes is the external face's served route table (spec §7, §14):
// Cloudflare Access-JWT auth on every /api/v1 route; the Admin entries are
// additionally gated on the admin Zero-Trust path. It exposes liveness only —
// readiness is an internal concern.
func (a *API) externalAPIRoutes() []apiRoute {
	return []apiRoute{
		{Method: "GET", Pattern: "/healthz", Public: true, h: a.handleHealthz},

		// App-auth tier: operations on your own servers (spec §14).
		{Method: "POST", Pattern: "/api/v1/servers/{name}/wake", h: a.handleWake},
		{Method: "POST", Pattern: "/api/v1/servers/{name}/stop", h: a.handleStop},
		{Method: "POST", Pattern: "/api/v1/servers/{name}/claim", h: a.handleClaim},
		// Console write (spec §8 写=RCON): owner/admin-gated inside the handler, so
		// it sits in the app-tier block (操作自己服 → app 鉴权), not behind adminOnly.
		{Method: "POST", Pattern: "/api/v1/servers/{name}/command", h: a.handleCommand},
		// Console read (spec §8 读=pods/log follow; §262 SSE). Owner/admin-gated inside
		// the handler, app-tier like the write side.
		{Method: "GET", Pattern: "/api/v1/servers/{name}/console", h: a.handleServerConsole},
		// Access / permissions (spec §7): structured whitelist / ban / LuckPerms
		// operations translated to RCON commands over the same owner-gated console
		// path as /command. An owner manages their OWN claimed node; an admin manages
		// ANY node — both via isOwnerOrAdmin inside issueAccessCommand, so these stay
		// app-tier (not behind adminOnly). Each command is assembled only from
		// strict-charset-validated structured fields (handlers_access.go).
		{Method: "POST", Pattern: "/api/v1/servers/{name}/access/whitelist", h: a.handleAccessWhitelist},
		{Method: "GET", Pattern: "/api/v1/servers/{name}/access/whitelist", h: a.handleAccessWhitelistList},
		{Method: "POST", Pattern: "/api/v1/servers/{name}/access/ban", h: a.handleAccessBan},
		{Method: "POST", Pattern: "/api/v1/servers/{name}/access/permission", h: a.handleAccessPermission},
		{Method: "POST", Pattern: "/api/v1/servers/{name}/access/group", h: a.handleAccessGroup},
		{Method: "GET", Pattern: "/api/v1/servers/{name}/status", h: a.handleStatus},
		// Identity self-read (spec §14 tiering): the panel reads this once at boot to
		// learn its own tier and decide which navigation surfaces to render. App-tier —
		// every authenticated principal may read its OWN identity. is_admin is the
		// server-computed Principal.IsAdmin() (Role + admin Access path), so the client
		// never re-derives the graded-ZT rule; it remains UX truth, not enforcement.
		{Method: "GET", Pattern: "/api/v1/me", h: a.handleMe},
		{Method: "GET", Pattern: "/api/v1/me/servers", h: a.handleMyServers},
		// World backups (spec §7, §466). Both are app-tier: GET /backups is scoped
		// inside the handler (admin sees all; a user sees only worlds they formerly
		// owned), and restore is gated by owner-or-admin PLUS a former-owner match, so
		// neither sits behind adminOnly.
		{Method: "GET", Pattern: "/api/v1/backups", h: a.handleListBackups},
		{Method: "POST", Pattern: "/api/v1/servers/{name}/restore-backup", h: a.handleRestoreBackup},
		// Account linking (spec §10), web side: /start reports link status (it is the
		// pointer handleClaim's 412 emits), /verify consumes the in-game code and binds
		// the account. App-tier, not admin — linking your own account is an ordinary
		// authenticated operation.
		{Method: "POST", Pattern: "/api/v1/account/link/start", h: a.handleLinkStart},
		{Method: "POST", Pattern: "/api/v1/account/link/verify", h: a.handleLinkVerify},
		// Modpack submission (user-directed lane over §16), user side: a user files an upload for review
		// and lists their own. App-tier — the submitter and the "my uploads" scope are
		// both taken from the principal, never the body, so an ordinary authenticated
		// session is the correct gate (the admin verdict lives below, behind adminOnly).
		{Method: "POST", Pattern: "/api/v1/me/submissions", h: a.handleCreateSubmission},
		{Method: "GET", Pattern: "/api/v1/me/submissions", h: a.handleMySubmissions},
		// Admin (Zero-Trust) tier: create / mutate spec / image admission. These gate
		// on Principal.IsAdmin() inside the handler via the adminOnly wrapper, so the
		// boundary is exercised even where the body is a later-phase stub.
		{Method: "POST", Pattern: "/api/v1/servers", Admin: true, h: a.handleCreateServer},
		{Method: "PATCH", Pattern: "/api/v1/servers/{name}", Admin: true, h: a.handlePatchServer},
		// SysAdmin fleet read: the whole-fleet lifecycle list for the cockpit's
		// FleetTable (panel/DESIGN-WEB-3SIDES.md — a frontend extension, not a spec §7
		// route). Admin-tier — it reads every owner's server, so it gates on the admin
		// Zero-Trust path, unlike the app-tier /me/servers. A path distinct from the
		// internal velocity GET /api/v1/servers on purpose: the parity test forbids one
		// {method, path} from carrying both the service and admin tiers.
		{Method: "GET", Pattern: "/api/v1/fleet", Admin: true, h: a.handleFleet},
		// Image build + whitelist (spec §16, §15). Every route is admin-tier: a build
		// is build-time RCE against the cluster, so submission requires the admin
		// Zero-Trust path, not merely an authenticated session.
		{Method: "POST", Pattern: "/api/v1/images/build", Admin: true, h: a.handleBuildImage},
		{Method: "GET", Pattern: "/api/v1/images/build/{id}", Admin: true, h: a.handleGetBuild},
		{Method: "GET", Pattern: "/api/v1/images/build/{id}/logs", Admin: true, h: a.handleBuildLogs},
		{Method: "POST", Pattern: "/api/v1/images/build/{id}/cancel", Admin: true, h: a.handleCancelBuild},
		{Method: "GET", Pattern: "/api/v1/images", Admin: true, h: a.handleListImages},
		{Method: "POST", Pattern: "/api/v1/images", Admin: true, h: a.handleAddImage},
		{Method: "DELETE", Pattern: "/api/v1/images", Admin: true, h: a.handleRemoveImage},
		// Modpack submission review (user-directed lane over §16), admin side: the queue of every user's
		// uploads and the approve/reject verdicts. Admin-tier — listing reads other
		// users' uploads and approving starts a build-time Kaniko job, so both require
		// the admin Zero-Trust path, exactly like a direct /images/build.
		{Method: "GET", Pattern: "/api/v1/submissions", Admin: true, h: a.handleListSubmissions},
		{Method: "POST", Pattern: "/api/v1/submissions/{id}/approve", Admin: true, h: a.handleApproveSubmission},
		{Method: "POST", Pattern: "/api/v1/submissions/{id}/reject", Admin: true, h: a.handleRejectSubmission},
	}
}

// InternalHandler builds the internal-face http.Handler: service-token auth, no
// Zero Trust (spec §14 red line). /healthz and /readyz are unauthenticated.
func (a *API) InternalHandler() http.Handler {
	return a.buildFace(a.internalAPIRoutes(), a.requireInternal)
}

// ExternalHandler builds the external-face http.Handler: Access-JWT auth on every
// /api/v1 route, with admin-tier routes additionally gated by the admin Access
// path inside their handlers.
func (a *API) ExternalHandler() http.Handler {
	return a.buildFace(a.externalAPIRoutes(), a.requireExternal)
}

// buildFace assembles one face from its route table. Public routes are mounted
// unauthenticated on the outer mux; the rest go on an inner mux behind guard
// (requireInternal / requireExternal), with Admin routes additionally wrapped in
// adminOnly. Because both faces are built from the same table the OpenAPI parity
// test reads, the served surface and the documented surface cannot drift apart
// without failing the build.
func (a *API) buildFace(routes []apiRoute, guard func(http.Handler) http.Handler) http.Handler {
	mux := http.NewServeMux()
	auth := http.NewServeMux()
	for _, rt := range routes {
		pattern := rt.Method + " " + rt.Pattern
		if rt.Public {
			mux.HandleFunc(pattern, rt.h)
			continue
		}
		h := rt.h
		if rt.Admin {
			h = a.adminOnly(rt.h)
		}
		auth.HandleFunc(pattern, h)
	}
	mux.Handle("/api/v1/", guard(auth))
	return a.baseChain(mux)
}

// baseChain wraps a handler in the cross-cutting middleware shared by both faces.
func (a *API) baseChain(h http.Handler) http.Handler {
	return withRequestID(withRecover(h))
}

// ---- request context plumbing ----

type ctxKey int

const (
	ctxKeyRequestID ctxKey = iota
	ctxKeyPrincipal
)

func requestIDFromContext(ctx context.Context) string {
	if v, ok := ctx.Value(ctxKeyRequestID).(string); ok {
		return v
	}
	return ""
}

// principalFromContext returns the authenticated external-face caller, or nil.
func principalFromContext(ctx context.Context) *Principal {
	if v, ok := ctx.Value(ctxKeyPrincipal).(*Principal); ok {
		return v
	}
	return nil
}

// ---- wake cooldown ----

// cooldownLimiter is an in-memory per-server rate limiter for the wake lever.
// It is process-local; with multiple api replicas the effective cooldown is
// per-replica, which is acceptable because the operator reconcile is idempotent.
type cooldownLimiter struct {
	mu     sync.Mutex
	now    func() time.Time
	last   map[string]time.Time
	window time.Duration
}

// allow reports whether name may wake now, recording the attempt when allowed.
func (c *cooldownLimiter) allow(name string, window time.Duration) bool {
	if window <= 0 {
		return true
	}
	c.mu.Lock()
	defer c.mu.Unlock()
	t := c.now()
	if last, ok := c.last[name]; ok && t.Sub(last) < window {
		return false
	}
	c.last[name] = t
	return true
}

// ---- running-server cap ----

// withinRunningCap reports whether waking info's server is allowed under the
// global running-server cap (spec §9.1: the concurrency-上限 lever hangs on the
// same wake chokepoint as cooldown and autostartPolicy). The cap counts servers
// whose spec.desiredState is already Running — CRD lifecycle truth read through
// ListServers, never the Postgres business layer (spec §1) — and admits the wake
// only while that count stays below the cap.
//
// Two short-circuits keep it both correct and cheap:
//
//   - A non-positive cap disables the lever (the default; mirrors WakeCooldown's
//     "zero disables"). §9.2 wires only autostartPolicy + cooldown as active wake
//     gates, so the cap ships inert and a deployment opts in by setting a positive
//     value. Disabled, it never lists the cluster — the default wake path pays
//     nothing for a lever nobody turned on.
//   - A target already desired-Running is idempotent: re-waking it adds no load,
//     so it is always admitted and need not be counted (and a stop→wake flip of a
//     server that was the Nth running one is never wedged by its own slot).
//
// Like the cooldown limiter this is a soft throttle, not a transactional
// invariant: the count-then-flip is not atomic, so a burst of concurrent wakes
// can momentarily exceed the cap. That is acceptable because the operator
// reconcile is idempotent and the §18 reaper / §9.3 quota bound steady-state
// load; the cap exists to refuse an obvious flood, not to hold a hard ceiling.
func (a *API) withinRunningCap(ctx context.Context, info *ServerInfo) (bool, error) {
	if a.MaxRunningServers <= 0 {
		return true, nil
	}
	if info.DesiredState == string(v1alpha1.DesiredRunning) {
		return true, nil
	}
	servers, err := a.Cluster.ListServers(ctx)
	if err != nil {
		return false, err
	}
	running := 0
	for _, s := range servers {
		if s.DesiredState == string(v1alpha1.DesiredRunning) {
			running++
		}
	}
	return running < a.MaxRunningServers, nil
}
+868 −0

File added.

Preview size limit exceeded, changes collapsed.

internal/api/auth.go

0 → 100644
+157 −0
Changes for internal/api/auth.go: 157 added lines, 0 removed lines.
Original line number Diff line number Diff line
package api

import (
	"crypto/subtle"
	"fmt"
	"net/http"
	"strings"

	"github.com/golang-jwt/jwt/v5"
)

// Principal is the authenticated external-face caller (spec §7, §14). The
// internal face (service token) never produces a Principal — it is a trusted
// machine caller, not a person.
type Principal struct {
	// UserID is the stable web identity (SSO subject → users.id).
	UserID string
	// Email is the audited actor identity (spec §14: audit actor = Access email).
	Email string
	// Role is "admin" or "user" (mirrors users.role).
	Role string
	// ViaAdminAccess is true only when the request arrived through the admin.*
	// Zero-Trust hostname (Cloudflare Access). Admin-tier operations require it
	// in addition to Role=="admin" (spec §14: ZT is graded by operation).
	ViaAdminAccess bool
}

// IsAdmin reports whether the principal may perform admin-tier operations.
// Both the role claim and the admin Access path are required: a role=admin
// session arriving on panel.* must not bypass the Zero-Trust boundary.
func (p *Principal) IsAdmin() bool {
	return p != nil && p.Role == "admin" && p.ViaAdminAccess
}

// InternalAuth authenticates the internal face (velocity / backend callbacks):
// a static service token presented as a Bearer credential. The internal face
// is never wrapped in Zero Trust (spec §1.8, §14 red line).
type InternalAuth interface {
	Authenticate(r *http.Request) error
}

// ExternalAuth authenticates the external face (people / panel) and returns the
// resolved Principal. Production verifies a Cloudflare Access JWT and checks its
// audience; the verification key source (JWKS) is injected so the audience and
// expiry logic stay unit-testable.
type ExternalAuth interface {
	Authenticate(r *http.Request) (*Principal, error)
}

// BearerTokenAuth is the production InternalAuth: a constant-time comparison
// against the configured service token. A zero token fails closed so a
// misconfiguration can never silently disable internal-face auth.
type BearerTokenAuth struct {
	Token string
}

// Authenticate checks the Authorization: Bearer header against the token.
func (b BearerTokenAuth) Authenticate(r *http.Request) error {
	if b.Token == "" {
		return fmt.Errorf("internal auth not configured")
	}
	got := bearerToken(r)
	if got == "" {
		return fmt.Errorf("missing bearer token")
	}
	if subtle.ConstantTimeCompare([]byte(got), []byte(b.Token)) != 1 {
		return fmt.Errorf("invalid service token")
	}
	return nil
}

// AccessVerifier is the production ExternalAuth: it parses a Cloudflare Access
// JWT, verifies the signature with the injected key function, and enforces the
// configured audience (spec §7 "验 aud"). AdminAudience, when set, marks a token
// minted for the admin.* application so admin-tier routes can require it.
type AccessVerifier struct {
	// Audience is the required `aud` claim for any external request.
	Audience string
	// AdminAudience, if non-empty and present in the token's aud set, flags the
	// principal as having passed the admin Zero-Trust path.
	AdminAudience string
	// Keyfunc resolves the signing key (production: a JWKS-backed keyfunc).
	Keyfunc jwt.Keyfunc
}

// accessClaims are the subset of Access JWT claims we consume.
type accessClaims struct {
	Email string `json:"email"`
	Role  string `json:"felis_role"`
	jwt.RegisteredClaims
}

// Authenticate verifies the Access JWT and maps it onto a Principal.
func (v AccessVerifier) Authenticate(r *http.Request) (*Principal, error) {
	if v.Keyfunc == nil {
		return nil, fmt.Errorf("external auth not configured")
	}
	raw := accessToken(r)
	if raw == "" {
		return nil, fmt.Errorf("missing access token")
	}

	var claims accessClaims
	parser := jwt.NewParser(jwt.WithExpirationRequired())
	if _, err := parser.ParseWithClaims(raw, &claims, v.Keyfunc); err != nil {
		return nil, fmt.Errorf("invalid access token: %w", err)
	}

	// Audience check: the configured app aud must be present. We do not delegate
	// to jwt.WithAudience so we can additionally detect the admin audience.
	if !audienceContains(claims.Audience, v.Audience) {
		return nil, fmt.Errorf("token audience does not include %q", v.Audience)
	}
	if claims.Subject == "" {
		return nil, fmt.Errorf("token missing subject")
	}

	role := claims.Role
	if role == "" {
		role = "user"
	}
	return &Principal{
		UserID:         claims.Subject,
		Email:          claims.Email,
		Role:           role,
		ViaAdminAccess: v.AdminAudience != "" && audienceContains(claims.Audience, v.AdminAudience),
	}, nil
}

// bearerToken extracts a Bearer credential from the Authorization header.
func bearerToken(r *http.Request) string {
	const prefix = "Bearer "
	h := r.Header.Get("Authorization")
	if len(h) > len(prefix) && strings.EqualFold(h[:len(prefix)], prefix) {
		return strings.TrimSpace(h[len(prefix):])
	}
	return ""
}

// accessToken prefers the Cloudflare Access assertion header, falling back to a
// Bearer credential so the same verifier works behind a proxy or directly.
func accessToken(r *http.Request) string {
	if h := r.Header.Get("Cf-Access-Jwt-Assertion"); h != "" {
		return h
	}
	return bearerToken(r)
}

// audienceContains reports whether want appears in the aud claim set.
func audienceContains(aud jwt.ClaimStrings, want string) bool {
	for _, a := range aud {
		if a == want {
			return true
		}
	}
	return false
}
+92 −0

File added.

Preview size limit exceeded, changes collapsed.

+140 −0

File added.

Preview size limit exceeded, changes collapsed.

Loading