oauth

package
v3.0.0 Latest Latest
Warning

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

Go to latest
Published: Sep 10, 2026 License: MIT Imports: 20 Imported by: 0

Documentation

Overview

Package oauth provides GitLab-specific OAuth 2.0 support for HTTP mode.

It verifies bearer tokens against GitLab's user endpoint, caches verified identities without storing raw token material, remembers rejections so a replayed bad token costs nothing upstream, and serves the RFC 9728 Protected Resource Metadata endpoint MCP clients use to discover the GitLab authorization server for a protected resource.

HTTP Mode Flow

The package participates in the HTTP transport path as follows:

HTTP request with Authorization: Bearer
    |
    v
RejectedTokens  --- hit ---> 401, no upstream call
    |
    | miss
    v
NewGitLabVerifier
    |
    +--> TokenCache hit ---> verified identity
    |
    v
GitLab /user (identity) and scope introspection

NewGitLabVerifier validates Bearer tokens with GitLab and stores verified identity metadata in TokenCache. A definitive rejection is remembered by RejectedTokens; an UpstreamError never is, because it says nothing about the credential.

NewProtectedResourceHandler serves OAuth Protected Resource Metadata so MCP clients can discover the GitLab authorization servers this deployment publishes — plural: a deployment may serve more than one instance, and the RFC 9728 field is an array. It advertises the scopes a client may authorize with, most capable first; see SupportedScopes.

Admission and recommendation are separate. MinimumScope is what a token must carry to be served at all, checked with SatisfiesMinimum, which treats api as covering read_api. RequiredScope is only what the challenge recommends for this deployment's full surface — a client asking for less is served less, not refused, because whether a given action may write is settled per action rather than at the door.

Both caches key on the instance as well as the token. A token means nothing away from the GitLab that issued it, so neither a verified identity nor a rejection may cross from one published instance to another.

Legacy PRIVATE-TOKEN headers are not normalized into Authorization here. OAuth mode is Bearer-only, so that what the WWW-Authenticate challenge advertises is exactly what is accepted, and legacy mode reads both headers directly.

Index

Constants

View Source
const (
	ScopeAPI     = "api"
	ScopeReadAPI = "read_api"
)

GitLab API scopes this server can operate under. api permits reads and writes; read_api is enough for a deployment that never mutates, and is what such a deployment asks for so users are not made to grant more.

View Source
const DefaultResourceDocumentation = "https://jmrp.io/docs/gitlab-mcp-server/operations/http-server/"

DefaultResourceDocumentation is the RFC 9728 resource_documentation value a deployment publishes when the operator names no page of their own.

It must be a page that actually resolves, which is not a given: this pointed at .../guides/oauth-app-setup/ for as long as the field has existed, and that path has never been served. The documentation site has a flat structure with no guides/ segment, and the OAuth application walkthrough lives only in the repository, so the closest page that exists is the HTTP server one, whose OAuth section links onward to the guide. A 404 is worse here than anywhere else it could appear: this URL is published in the protected-resource metadata every OAuth client fetches, and some of them show it on the consent screen a person is being asked to trust.

View Source
const MetadataPath = "/.well-known/oauth-protected-resource"

MetadataPath is the RFC 9728 §3 well-known URI suffix for protected-resource metadata. On its own it is the metadata path of the resource that *is* the origin; a resource with a path of its own derives a longer one through MetadataPathFor.

View Source
const MinimumScope = ScopeReadAPI

MinimumScope is the least a token must carry to be admitted at all. It is what the door checks.

The door used to check the deployment's own scope instead, which made a property of the server into a demand on every caller: a deployment that serves writes refused a read_api token at initialize, before it could list the tools it was entitled to call. Whether a given action may write is known per action — the catalog says so — so admission asks only for the scope every action needs, and the tool surface an entry gets is narrowed to match the authority its token actually carries.

Variables

View Source
var ErrInsufficientScope = errors.New("token lacks the required GitLab scope")

ErrInsufficientScope reports a credential GitLab accepts as genuine but which does not carry the scope the request needed.

It is deliberately distinct from auth.ErrInvalidToken. Reporting the two the same way tells a client to discard a working credential and re-run its authorization flow, which returns the same under-scoped token and loops; the action that resolves it is a step-up request naming the scope. It also matters upstream of that: an invalid token is charged against the caller's authentication-failure budget, and charging a genuine one lets a client lock its own address out of the endpoint while holding a perfectly good token.

View Source
var ErrRecipientUnverifiable = errors.New("token recipient could not be verified")

ErrRecipientUnverifiable reports that the recipient check could not be made: introspection answered nothing, so the token's owning application is unknown.

The request is still refused, because a pin that admits what it cannot verify is not a pin. What must not happen is calling it a verdict. It is separate from ErrUnacceptedRecipient so the caller does not tell the holder of a perfectly admissible token that it belongs to another application, and does not cache that non-answer: RejectedTokens.Record is documented for definitive rejections only, since caching an upstream failure locks out a valid token for the whole TTL over a transient outage.

View Source
var ErrUnacceptedRecipient = errors.New("token was not issued to an OAuth application this deployment admits")

ErrUnacceptedRecipient reports a token the instance accepts but this deployment does not: it was minted for an OAuth application outside --oauth-client-uid, or for none at all.

It is separate from auth.ErrInvalidToken because the honest message is the opposite one. GitLab rejected nothing; the credential is genuine, unexpired and valid for the instance, and telling its holder otherwise sends them to reauthorize and come back with the very same token. The distinction also keeps this off the authentication-failure budget, for the reason spelled out on ErrInsufficientScope above: this shape of refusal is not a guess at a secret, and charging it lets one client lock a shared address out of the endpoint while holding a perfectly good credential.

Functions

func MetadataPathFor

func MetadataPathFor(resourceID string) string

MetadataPathFor returns the one path on which a deployment publishing resourceID serves its protected-resource document: the path component of MetadataURLFor, so what a server mounts and what its challenge advertises cannot drift apart.

It exists as its own function, rather than inline at the mount, because a host can run several MCP servers and each of them has to get this right. This one used to mount the document twice, the second time under a "/{rest...}" wildcard, so every suffix returned the same body: asked about the neighbor at /libgen, it answered with a document naming *this* deployment's resource and demanding OAuth against this deployment's GitLab. RFC 9728 §3.3 tells a client to discard a document whose resource value is not the identifier it derived the URL from, so the extra paths bought a conforming client nothing and told a lax one something false.

The path-less form is served only when the resource identifier has no path, and that is a decision rather than an omission. /.well-known/oauth-protected-resource is by construction the metadata of the resource at the host root: a server mounted at /gitlab answering it is claiming an identity that is not its own, which is the wildcard's harm in a different spelling. Where --public-url has no path the bare form *is* the derived form, so this is one rule and not two cases, and a server that owns its hostname loses nothing.

A trailing slash is trimmed because the derived path is mounted as an exact http.ServeMux pattern, and a pattern ending in "/" is a subtree match in Go: keeping the slash would quietly restore the wildcard this replaced.

func MetadataURLFor

func MetadataURLFor(resourceID string) string

MetadataURLFor derives the RFC 9728 §3 protected-resource metadata URL from a resource identifier: the well-known path segment is inserted between the host component and the resource's path, so https://mcp.example.com/gitlab advertises its metadata at https://mcp.example.com/.well-known/oauth-protected-resource/gitlab and a path-less resource at the bare well-known URI. The identifier is validated at config load; a parse failure here would be a programming error, so the fallback simply appends the well-known path.

func NewGitLabVerifier

func NewGitLabVerifier(gitlabURL string, skipTLS bool, cacheTTL time.Duration, cache *TokenCache, clientUIDs ...string) auth.TokenVerifier

NewGitLabVerifier returns an auth.TokenVerifier that validates Bearer tokens by calling the GitLab /api/v4/user endpoint. Verified identities are cached in cache (if non-nil) to avoid redundant API calls.

The returned verifier populates auth.TokenInfo with:

  • UserID: the GitLab user's numeric ID (as string)
  • Extra["username"]: the GitLab user's login name
  • Expiration: now + cacheTTL, shortened to the token's own expiry when the instance reports one (so the SDK middleware honors both)

The raw token is deliberately not carried in Extra. It used to be, "for downstream GitLab client creation", but nothing downstream ever read it: the pool takes the credential from the request header, and the SDK reads only UserID, Scopes and Expiration. What carrying it did do was put every verified bearer, in the clear, into the TokenCache for the length of the TTL, while this package's own documentation promised the cache held no raw token material. The MCP security considerations name "tokens cached or logged on the server" as the theft surface; the cache is keyed by digest so that a memory dump yields no credential, and the value has to keep that promise too.

func NewGitLabVerifierFor

func NewGitLabVerifierFor(resolve InstanceResolver, skipTLS bool, cacheTTL time.Duration, cache *TokenCache, clientUIDs ...string) auth.TokenVerifier

NewGitLabVerifierFor is NewGitLabVerifier for a deployment that publishes more than one instance.

Verification is per instance because a token means nothing away from the GitLab that issued it: sending it to the wrong one either fails or, worse, succeeds against an instance where the same string happens to be valid for somebody else. The resolver is the operator's allow-list made executable, and the cache is keyed by instance and token together for the same reason. clientUIDs, when non-empty, pins the OAuth applications whose tokens this deployment admits — see [acceptedRecipient]. It is a set rather than a scalar because --gitlab-url is repeatable and each published instance has its own OAuth application with its own uid.

func NewProtectedResourceHandler

func NewProtectedResourceHandler(resourceURL string, gitlabURLs, supportedScopes []string, links ResourceLinks) http.Handler

NewProtectedResourceHandler returns an http.Handler that serves RFC 9728 Protected Resource Metadata. MCP clients use this endpoint to discover the GitLab authorization server associated with this resource.

supportedScopes are the scopes a client may authorize with, most capable first, which it reads from scopes_supported to build its authorization request. A deployment that can write lists api and read_api: the first for a client that wants the whole surface, the second for one that wants a credential which cannot mutate anything and is served a read-only surface accordingly. A deployment that never mutates lists only read_api, so no user is asked to grant more than it can use.

links.Documentation is as close as RFC 9728 permits a resource server to come to telling a client which client to be: the specification defines no field carrying a client identifier, and §5.3 leaves establishing one "out of scope".

The handler is registered at the single path MetadataPathFor derives from the resource identifier.

func RequiredScope

func RequiredScope(readOnly, safeMode bool) string

RequiredScope reports the scope a client should ask for to get this deployment's full surface: read_api when no request can reach GitLab as a write, api otherwise. Safe mode counts as read-only here because it answers mutating calls with a preview instead of forwarding them.

It is a recommendation, published in the challenge and as the first entry of SupportedScopes — not an admission requirement. A client that asks for less is served less, not refused.

func SatisfiesMinimum

func SatisfiesMinimum(granted []string, minimum string) bool

SatisfiesMinimum reports whether the scopes GitLab granted a token meet the minimum a deployment admits.

api is treated as covering read_api. [expandImpliedScopes] already writes that relationship into what the verifier returns, but this check is what the door depends on, and a plain set containment here would make admission hinge on a normalization step somewhere else: the day a token's scopes arrive by another route, every api-only token gets a 403 for lacking a scope it strictly supersedes.

func SupportedScopes

func SupportedScopes(readOnly, safeMode bool) []string

SupportedScopes lists the scopes a client may authorize with, most capable first, for RFC 9728 scopes_supported.

A deployment that can write advertises both: api for a client that wants the whole surface, read_api for one that deliberately wants a credential that cannot break anything — a browser-based inspector, a dashboard, any read-only integration. Listing only api forced every such client to hold a write-capable token or stay out.

Types

type InstanceResolver

type InstanceResolver func(*http.Request) (string, error)

InstanceResolver reports which GitLab instance a request must be verified against. It is a pure function of the request: the guard in front calls it to reject an instance the deployment does not publish, and the verifier calls it again to know where to send the verification, and the two must agree without sharing state.

type RejectedTokens

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

RejectedTokens remembers the tokens GitLab has already refused, so a client replaying one does not cost an upstream verification call every time.

Without it, each request carrying an invalid Bearer token is relayed 1:1 to the GitLab instance. That turns a public deployment into an amplifier: unauthenticated traffic anyone can generate becomes load on someone else's API, and on gitlab.com it becomes rate-limit pressure charged to the server's own address, where it lands on the legitimate users sharing it.

Entries are keyed by the same SHA-256 digest TokenCache uses, never the raw credential — a mistyped valid token must not be left lying in memory in the clear. The map is bounded because its keys are supplied by whoever is calling: an unbounded one would trade an amplification vector for a memory exhaustion vector.

func NewRejectedTokens

func NewRejectedTokens(capacity int, ttl time.Duration) *RejectedTokens

NewRejectedTokens returns a cache holding at most capacity rejections, each for ttl. A non-positive capacity or ttl disables caching entirely: every method still works, and Contains simply never reports a hit, so a deployment that wants no negative cache loses the amplification defense rather than crashing.

func (*RejectedTokens) Cleanup

func (r *RejectedTokens) Cleanup()

Cleanup drops every expired entry. Intended for periodic maintenance, so an idle server does not hold rejections until the next request evicts them.

func (*RejectedTokens) Contains

func (r *RejectedTokens) Contains(gitlabURL, token string) bool

Contains reports whether the token was rejected recently enough to answer from memory. An expired entry is dropped on the way out, so a caller never sees a stale rejection.

func (*RejectedTokens) Len

func (r *RejectedTokens) Len() int

Len returns the number of entries held, expired ones included.

func (*RejectedTokens) Lookup

func (r *RejectedTokens) Lookup(gitlabURL, token string) (RejectionKind, bool)

Lookup returns why a token was refused, and whether the refusal still applies. An expired entry is dropped on the way out.

func (*RejectedTokens) Record

func (r *RejectedTokens) Record(gitlabURL, token string)

Record notes that GitLab rejected this token.

Only a definitive rejection belongs here. An upstream failure — a timeout, a 5xx, a 429 — says nothing about the credential, and caching one would lock out a valid token for the whole TTL over a transient outage.

func (*RejectedTokens) RecordKind

func (r *RejectedTokens) RecordKind(gitlabURL, token string, kind RejectionKind)

RecordKind notes a refusal and why, so RejectedTokens.Lookup can reproduce it rather than collapsing every cached refusal into GitLab's verdict.

type RejectionKind

type RejectionKind int

RejectionKind records why a token was refused, so an answer served from this cache is the same answer the caller would have got from the round trip.

Without it a cached refusal degrades to the harshest available response: an unadmitted recipient would be reported as a token GitLab rejected, and would be charged the authentication-failure budget the first refusal deliberately spared it.

const (
	// RejectionInvalid is GitLab's own verdict on the credential.
	RejectionInvalid RejectionKind = iota
	// RejectionUnaccepted is this deployment's: the instance accepts the
	// token, but it was not issued to an admitted OAuth application.
	RejectionUnaccepted
)
type ResourceLinks struct {
	// Documentation is resource_documentation: a page describing the OAuth
	// application a client should use here.
	Documentation string
	// Policy is resource_policy_uri: how this deployment handles the data
	// reached with the tokens it accepts.
	Policy string
	// TermsOfService is resource_tos_uri: the terms under which it is offered.
	TermsOfService string
}

ResourceLinks carries the RFC 9728 URL fields an operator can point at their own pages.

They are grouped because they answer one question — where a person goes to find out what this deployment is and what it does with their access — and because three positional strings on one constructor would be easy to pass in the wrong order.

Documentation defaults to this project's HTTP server mode page when empty (the OAuth setup guide's path is not served on the documentation site), since a client that finds no guidance at all is worse off than one sent to generic instructions. Policy and TermsOfService have no such default: they describe a specific deployment's undertakings, and this project cannot make them on an operator's behalf.

func (ResourceLinks) DocumentationURL

func (l ResourceLinks) DocumentationURL() string

DocumentationURL is the page the metadata document publishes as resource_documentation: the operator's own when one was named, and DefaultResourceDocumentation otherwise.

It is a method rather than a line inside NewProtectedResourceHandler because the same page is named a second time, as the RFC 6750 error_uri of the refusal a pinned deployment gives a token from another application. That refusal tells the holder to read the resource documentation; two call sites resolving the default separately is how the challenge and the document would come to name different pages.

type TokenCache

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

TokenCache is a thread-safe, TTL-based cache for verified token identities. Keys are SHA-256 hashes of the instance URL and the raw token, so no sensitive material is stored.

The instance is part of the key, not an afterthought. A token is only ever valid for the GitLab that issued it, so a cache keyed by the token alone would let a deployment publishing more than one instance accept a credential verified against the first as proof of identity on the second.

func NewTokenCache

func NewTokenCache() *TokenCache

NewTokenCache creates an empty TokenCache.

func (*TokenCache) Cleanup

func (c *TokenCache) Cleanup()

Cleanup removes all expired entries. Intended for periodic maintenance.

func (*TokenCache) Delete

func (c *TokenCache) Delete(gitlabURL, token string)

Delete is an alias for [Evict] for API ergonomics.

func (*TokenCache) Evict

func (c *TokenCache) Evict(gitlabURL, token string)

Evict removes the cache entry for the given raw token.

func (*TokenCache) Get

func (c *TokenCache) Get(gitlabURL, token string) (*auth.TokenInfo, bool)

Get returns the cached auth.TokenInfo for the given raw token if present and not expired. Expired entries are lazily evicted on read.

func (*TokenCache) Len

func (c *TokenCache) Len() int

Len returns the total number of entries (including potentially expired ones).

func (*TokenCache) Put

func (c *TokenCache) Put(gitlabURL, token string, info *auth.TokenInfo, ttl time.Duration)

Put stores a auth.TokenInfo for the given raw token with the specified TTL.

func (*TokenCache) RunCleanup

func (c *TokenCache) RunCleanup(ctx context.Context, interval time.Duration)

RunCleanup sweeps expired entries every interval until ctx is done. It blocks, so callers run it in their own goroutine.

Reads already evict lazily, which is enough for a token that comes back: its own entry is dropped the next time it is looked up. It is not enough for one that does not. Every distinct bearer this deployment has ever verified holds a map entry for the lifetime of the process — a scanner walking an endpoint with fresh credentials, or a fleet whose tokens rotate, leaves entries nothing will ever read again. The sweep is what makes the TTL a bound on memory and not only on staleness.

type UpstreamError

type UpstreamError struct {
	// Status is the HTTP status GitLab returned, or 0 when the request never
	// produced a response at all.
	Status int
	// RetryAfter is the delay GitLab asked for, or 0 when it did not say.
	RetryAfter time.Duration
	// Err is the underlying cause.
	Err error
}

UpstreamError reports that the GitLab instance could not answer the verification request, as opposed to answering that the token is bad.

The distinction is not cosmetic. Reporting a throttled or unreachable GitLab as an invalid token tells a well-behaved MCP client to discard a perfectly good credential and start a fresh authorization flow — which generates more upstream traffic at the exact moment the instance asked for less, and asks the user to re-approve an application that was never the problem.

func (*UpstreamError) Error

func (e *UpstreamError) Error() string

Error describes the failure, naming the status when GitLab answered with one and saying it was unreachable when nothing came back at all.

func (*UpstreamError) Unwrap

func (e *UpstreamError) Unwrap() error

Unwrap exposes the underlying cause to errors.Is and errors.As.

Jump to

Keyboard shortcuts

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