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
verification slot --- none free in time, not cached meanwhile ---> ErrVerificationBusy
|
+--> TokenCache hit, verified meanwhile ---> 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, and neither is ErrVerificationBusy.
Both things on this path that grew with what callers send are bounded. The identity cache holds a fixed number of identities and makes room by dropping an expired one or, when none has expired, the one used least recently; the verifier sends GitLab its round trips under a fixed number of slots, counting work rather than callers, so a token the cache does not hold waits for one briefly and is refused when none frees. A token the cache holds is answered before a slot is asked for, and the cache is read again when the wait ends and once a slot is held, so a request that waited behind another carrying the same new token finds the identity that one verified.
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 one scope a client should authorize with; 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
- Variables
- func MetadataPathFor(resourceID string) string
- func MetadataURLFor(resourceID string) string
- func NewGitLabVerifier(gitlabURL string, skipTLS bool, cacheTTL time.Duration, cache *TokenCache, ...) auth.TokenVerifier
- func NewGitLabVerifierFor(resolve InstanceResolver, skipTLS bool, cacheTTL time.Duration, ...) auth.TokenVerifier
- func NewProtectedResourceHandler(resourceURL string, gitlabURLs, supportedScopes []string, links ResourceLinks) http.Handler
- func QuotedDescription(raw string) string
- func RequiredScope(readOnly, safeMode bool) string
- func SatisfiesMinimum(granted []string, minimum string) bool
- func SupportedScopes(readOnly, safeMode bool) []string
- type InstanceResolver
- type PermissionMissingError
- type RejectedTokens
- func (r *RejectedTokens) Cleanup()
- func (r *RejectedTokens) Contains(gitlabURL, token string) bool
- func (r *RejectedTokens) Len() int
- func (r *RejectedTokens) Lookup(gitlabURL, token string) (RejectionKind, bool)
- func (r *RejectedTokens) LookupRefusal(gitlabURL, token string) (RejectionKind, string, bool)
- func (r *RejectedTokens) Record(gitlabURL, token string)
- func (r *RejectedTokens) RecordBelowMinimum(gitlabURL, token string)
- func (r *RejectedTokens) RecordKind(gitlabURL, token string, kind RejectionKind)
- func (r *RejectedTokens) RecordPermissionMissing(gitlabURL, token, description string)
- type RejectionKind
- type ResourceLinks
- type TokenCache
- func (c *TokenCache) Cleanup()
- func (c *TokenCache) Delete(gitlabURL, token string)
- func (c *TokenCache) Evict(gitlabURL, token string)
- func (c *TokenCache) Get(gitlabURL, token string) (*auth.TokenInfo, bool)
- func (c *TokenCache) Len() int
- func (c *TokenCache) Put(gitlabURL, token string, info *auth.TokenInfo, ttl time.Duration)
- func (c *TokenCache) RunCleanup(ctx context.Context, interval time.Duration)
- type UpstreamError
Constants ¶
const ( ScopeAPI = "api" ScopeReadAPI = "read_api" )
GitLab API scopes this server can operate under. api permits reads and writes; read_api is what a deployment that never mutates asks for so users are not made to grant more, although GitLab answers two of its reads only from api, because they are sent as a POST (ADR-0026).
const DefaultResourceDocumentation = "https://jmrp.io/docs/gitlab-mcp-server/operations/oauth-app/"
DefaultResourceDocumentation is the RFC 9728 resource_documentation value a deployment publishes when the operator names no page of their own.
It is the documentation site's OAuth application page, which is the page the field exists to point at: RFC 9728 defines no field for a client identifier, so a resource server can tell a client which OAuth application to use only through a page that says so, and this one says how to create that application on GitLab, which scope to check and which redirect URIs each client sends. An operator who has created one names their own page, carrying its Application ID, through --resource-documentation.
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 had existed, a path the site has never served, and then at the HTTP server page while the walkthrough lived only in the repository. 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. The test beside it holds the URL to a page of the site.
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.
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.
const ScopesAssumedKey = "scopes_assumed"
ScopesAssumedKey is the key of the verified auth.TokenInfo's Extra under which true travels when no introspection endpoint answered and the scopes are this package's assumption (api, kept so a deployment whose instance does not describe its tokens still works) rather than GitLab's answer. Nothing has then said whether the token is a fine-grained one, so the pool asks the self endpoint itself, at the entry's build and on each accepted revalidation until it answers, instead of reading the assumption as a classic token for the entry's life. It is absent whenever an endpoint answered.
const TokenIDKey = "token_id"
TokenIDKey is the key of the verified auth.TokenInfo's Extra under which a personal access token's own id travels, as an int64, when the self endpoint reported one. It is absent for an OAuth access token.
The pool reads it from the request's token info, which came out of this package's cache, keyed on the instance and the token, never from anything keyed on the token alone, so the id an entry reads a grant by is the one the same instance gave for the same token. It is never logged: it names the token as well as its value does for a reader of the instance's audit log.
Variables ¶
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.
var ErrPermissionMissing = errors.New("token lacks the fine-grained permission to read its own user")
ErrPermissionMissing reports a token GitLab accepts as genuine whose fine-grained grant cannot read its own user: GET /api/v4/user answered 403 with insufficient_granular_scope. PermissionMissingError carries GitLab's sentence for it.
It is apart from ErrInsufficientScope because the way out differs. A classic token lacking a scope is fixed by reauthorizing with the named scope; a fine-grained token's grant cannot be changed after it is created, so the holder needs a new token that grants User: Read, or a classic one, and being told to reauthorize with api would send them to an authorization flow that cannot help. It is apart from auth.ErrInvalidToken for the reason ErrInsufficientScope is: the credential is valid, so it is not charged to the caller's authentication-failure budget.
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.
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.
var ErrVerificationBusy = errors.New("the token could not be verified right now; retry shortly")
ErrVerificationBusy reports that every verification slot stayed taken for as long as the request waited for one.
Like UpstreamError it says nothing about the credential: the token was never sent anywhere. So it must be answered as "retry later", never cached as a rejection and never charged to an authentication budget, or a flood of invented tokens would lock out the valid ones arriving beside it.
Its text says nothing about why, because it can reach the caller: the bearer guard answers it in words of its own, but the SDK's middleware, verifying a second time, writes a verifier error it does not recognize into its own answer. A sentence about saturation there would tell a caller that other callers are verifying (INV-019).
Functions ¶
func MetadataPathFor ¶
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 ¶
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.
One verifier is one set of [verificationSlots]: a token the cache does not hold is verified only while one of them is free, and waits at most [verificationWait] for one before it is refused with ErrVerificationBusy. The server builds a single verifier, so the slots are the process's.
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 is the scope a client should authorize with, which it reads from scopes_supported to build its authorization request: api on a deployment that can write, read_api on one that never mutates, so no user is asked to grant more than it can use. One scope, never both: a client asks for everything listed, and GitLab refuses a request naming a scope the application does not have. See SupportedScopes.
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 QuotedDescription ¶ added in v3.2.0
QuotedDescription returns GitLab's sentence as a door may quote it to a caller: printable ASCII only, every other rune (a control character, a quote's lookalike, a byte of another script) written as one space, runs of spaces collapsed, and cut at 512 bytes.
The sentence comes from whichever instance the request selected, which under --allow-any-gitlab-url is the caller's own, and a caller is told it in a JSON-RPC message body. The challenge a door sends never carries it: RFC 6749 section 5.2 allows error_description a narrower set than this, and the challenge's text is the door's own constant.
func RequiredScope ¶
RequiredScope reports the scope a client should ask for: 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.
Under read-only or safe mode it is the least the deployment recommends, not what buys its whole surface: a read_api token is served the actions GitLab accepts from read_api (ADR-0026), which leaves out the two reads GitLab answers only from api, template.lint and project.dependency_firewall_evaluate. Recommending api there would ask every user of a deployment that never writes for a scope that can.
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 ¶
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.
A fine-grained personal access token meets any minimum (gitlabclient.FineGrained). Its one legacy scope, granular, names no authority: what it may do is a grant GitLab judges per call, so it is unknown authority (ADR-0018, ADR-0024), admitted as a classic token whose scopes are unknown is, and refused by GitLab itself on the call its grant does not cover.
The read_api minimum, the one MinimumScope names, is gitlabclient.MeetsMinimum, the predicate the legacy door and stdio ask too, so a scope that reaches the API is the same scope at every door. Nil granted scopes are the verifier's answer that the token carries none, and do not meet it.
func SupportedScopes ¶
SupportedScopes lists the scope published as RFC 9728 scopes_supported: api on a deployment that can write, read_api on one that never mutates.
One scope, because a client that reads the list asks GitLab for every scope in it, and GitLab refuses an authorization request naming any scope the OAuth application does not have. Listing api and read_api together made Claude Code send scope=api read_api, which an application with only api checked, or only read_api, answers with invalid_scope.
This is what the deployment asks for, not what it admits. A read_api token is admitted by a deployment that can write and served the actions GitLab accepts from read_api (see MinimumScope); a client that wants such a credential names read_api itself, as a browser-based inspector does, from an application that has it.
Types ¶
type InstanceResolver ¶
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 PermissionMissingError ¶ added in v3.2.0
type PermissionMissingError struct {
// Description is GitLab's error_description, unfiltered: the instance's
// own text, which a door bounds and filters with [QuotedDescription]
// before quoting it.
Description string
}
PermissionMissingError is ErrPermissionMissing with the sentence GitLab gave, as the instance sent it.
func (*PermissionMissingError) Error ¶ added in v3.2.0
func (e *PermissionMissingError) Error() string
Error names the verdict and never the sentence, so a log line built from the error carries no text the instance chose.
func (*PermissionMissingError) Unwrap ¶ added in v3.2.0
func (e *PermissionMissingError) Unwrap() error
Unwrap lets errors.Is find ErrPermissionMissing.
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. It is RejectedTokens.Lookup without the reason, so an expired entry is dropped on the way out and 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) LookupRefusal ¶ added in v3.2.0
func (r *RejectedTokens) LookupRefusal(gitlabURL, token string) (RejectionKind, string, bool)
LookupRefusal is RejectedTokens.Lookup with the sentence a RejectionPermissionMissing refusal was recorded with, already bounded and filtered as QuotedDescription leaves it, and empty for every other kind.
There is no "disabled" check here. One used to sit at the top of this method and of Contains, repeating the one in RejectedTokens.RecordKind; since a disabled cache never stores anything, the lookup below misses on it anyway, and the copies could change no answer.
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) RecordBelowMinimum ¶ added in v3.2.0
func (r *RejectedTokens) RecordBelowMinimum(gitlabURL, token string)
RecordBelowMinimum notes that GitLab accepted this token and that it carries neither read_api nor api (RejectionBelowMinimum). Every door records it through this one method, the fresh refusal's and the pool's alike, so the refusal served from here is the one the round trip produced.
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.
func (*RejectedTokens) RecordPermissionMissing ¶ added in v3.2.0
func (r *RejectedTokens) RecordPermissionMissing(gitlabURL, token, description string)
RecordPermissionMissing notes that GitLab accepted this token and refused it the permission to read its own user, keeping the sentence GitLab gave as a door quotes it (QuotedDescription), so the refusal served from here is the one the round trip produced, word for word.
The sentence is bounded before it is stored, because the cache holds up to its capacity of them and the text is the instance's, which under --allow-any-gitlab-url is the caller's own.
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 // RejectionPermissionMissing is GitLab's verdict that the token is // genuine and its fine-grained grant cannot read its own user, which // every door here needs (GET /api/v4/user answered 403 with // insufficient_granular_scope). It is cached, uncharged like the fresh // answer, because nothing at this release changes a grant after its token // is created, so the verdict holds for the token's life; the entry's TTL // bounds how long a feature flag an administrator turns on stays unseen. RejectionPermissionMissing // RejectionBelowMinimum is the verdict that the token is genuine and // carries neither read_api nor api, the minimum every door admits at // (issue 952): GET /api/v4/user answered 403 with insufficient_scope, or // the token's own description named only scopes below it. It is cached, // uncharged like the fresh answer, because a token's scopes cannot change // after it is created: GitLab has no route that edits a personal access // token's scopes, and an OAuth token granted more scopes is a new token. RejectionBelowMinimum )
type ResourceLinks ¶
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 OAuth application page when empty (DefaultResourceDocumentation), 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, bounded in size. 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.
Every read that finds a live entry marks it used, and a full cache makes room by dropping an expired entry or, when none has expired, the entry used least recently (see [identityCacheCapacity]). That is why a read takes the same lock a write does: finding an entry changes the order the cache would evict in.
func NewTokenCache ¶
func NewTokenCache() *TokenCache
NewTokenCache creates an empty TokenCache holding at most [identityCacheCapacity] identities.
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, and marks the entry as used. Expired entries are lazily evicted on read.
The expiry is judged under the same lock that removes the entry, so an entry another request stored a moment ago is never removed because an older one under its key had expired.
func (*TokenCache) Len ¶
func (c *TokenCache) Len() int
Len returns the total number of entries (including potentially expired ones).
func (*TokenCache) Put ¶
Put stores a auth.TokenInfo for the given raw token with the specified TTL.
A key already held is refreshed in place. A new key in a full cache first makes room, dropping every expired entry or, when none has expired, the entry used least recently, so the cache never holds more than its capacity.
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. A scanner walking an endpoint with fresh credentials, or a fleet whose tokens rotate, leaves entries nothing will ever read again, and the capacity only drops them once the cache is full. The sweep is what gives the memory back when the traffic that grew the cache has stopped.
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.