Documentation
¶
Overview ¶
Package oauth implements the minimal embedded OAuth 2.1 authorization server that fronts the MCP resource. SolidPing is both the resource server (the MCP endpoint) and the authorization server for that resource: it serves the RFC 9728 / RFC 8414 discovery documents, drives the authorization-code + PKCE flow against the existing user session, mints audience-bound JWT access tokens via the existing auth-service signing key, and supports RFC 7591 dynamic client registration for native clients (Claude Desktop / mcp-remote).
Scope of v1: a single issuer (cfg.Server.BaseURL); the org is taken from the logged-in user's session at /authorize. Tokens reuse the existing HS256 JWT machinery — only the discovery, consent, code/token, and DCR surfaces are new.
Index ¶
- Constants
- func IsLoopbackRedirectURI(redirectURI string) bool
- func IsValidRedirectURI(redirectURI string) bool
- func ParseScopes(scope string) []string
- func RedirectURIAllowed(requestedURI string, registered []string) bool
- func ScopesValid(scopes []string) bool
- func TokenHasResourceAudience(claims *auth.Claims, resource string) bool
- func VerifyPKCE(codeVerifier, codeChallenge string) bool
- type AuthCodeGrant
- type AuthorizationServerMetadata
- type Handler
- func (h *Handler) ApproveAuthorize(writer http.ResponseWriter, req *http.Request) error
- func (h *Handler) AuthorizationServerMetadata(w http.ResponseWriter, _ *http.Request) error
- func (h *Handler) Authorize(writer http.ResponseWriter, req *http.Request) error
- func (h *Handler) JWKS(w http.ResponseWriter, _ *http.Request) error
- func (h *Handler) ProtectedResourceMetadata(w http.ResponseWriter, _ *http.Request) error
- func (h *Handler) Register(writer http.ResponseWriter, req *http.Request) error
- func (h *Handler) Revoke(writer http.ResponseWriter, req *http.Request) error
- func (h *Handler) Token(writer http.ResponseWriter, req *http.Request) error
- type JWK
- type JWKS
- type Metadata
- func (m Metadata) AuthorizationEndpoint() string
- func (m Metadata) BuildAuthorizationServerMetadata() AuthorizationServerMetadata
- func (m Metadata) BuildProtectedResourceMetadata() ProtectedResourceMetadata
- func (m Metadata) JWKSURI() string
- func (m Metadata) ProtectedResourceMetadataURL() string
- func (m Metadata) RegistrationEndpoint() string
- func (m Metadata) ResourceURL() string
- func (m Metadata) RevocationEndpoint() string
- func (m Metadata) TokenEndpoint() string
- type ProtectedResourceMetadata
- type Service
- func (s *Service) ExchangeAuthCode(ctx context.Context, code, clientID, redirectURI, codeVerifier string) (*TokenResult, error)
- func (s *Service) ExchangeRefreshToken(ctx context.Context, refreshToken, clientID string) (*TokenResult, error)
- func (s *Service) GetClient(ctx context.Context, clientID string) (*models.OAuthClient, error)
- func (s *Service) IssueAuthCode(ctx context.Context, grant *AuthCodeGrant) (string, error)
- func (s *Service) RegisterClient(ctx context.Context, name string, redirectURIs, grantTypes, scopes []string, ...) (*models.OAuthClient, string, error)
- func (s *Service) RevokeGrant(ctx context.Context, refreshToken, clientID string) (bool, error)
- func (s *Service) SetClock(c clock.Clock)
- type TokenResult
Constants ¶
const ( // CLIClientID is the pre-registered public client_id used by `sp auth login`. CLIClientID = "solidping-cli" // CLIClientName is the human-readable name stored for the CLI client and // shown on the consent screen. CLIClientName = "SolidPing CLI" // CLIRedirectURI is the registered loopback redirect. Per RFC 8252 §7.3 the // port is ignored by RedirectURIAllowed, so this single registered entry // covers every ephemeral 127.0.0.1:<port>/callback the CLI binds. CLIRedirectURI = "http://127.0.0.1/callback" // CLICallbackPath is the path the CLI's loopback listener serves and the // path component the registered redirect matches on. CLICallbackPath = "/callback" )
Well-known identifiers for the first-party SolidPing CLI OAuth client: a pre-registered public client with a loopback redirect, seeded idempotently at server startup (see app.SeedCLIOAuthClient) so no dynamic registration is needed.
`sp auth login` no longer uses it: since spec 2026-08-08-02 the CLI logs in through the RFC 8628 device authorization flow (/api/v1/auth/device), which also works over SSH and on headless machines where a loopback redirect cannot reach the CLI. The registration is kept for any other native client that still drives the loopback authorization-code flow against /oauth/authorize.
const ( ErrInvalidRequest = "invalid_request" ErrInvalidClient = "invalid_client" ErrInvalidGrant = "invalid_grant" ErrUnsupportedGrantType = "unsupported_grant_type" ErrUnsupportedResponseType = "unsupported_response_type" ErrInvalidScope = "invalid_scope" ErrAccessDenied = "access_denied" ErrServerError = "server_error" ErrInvalidRedirectURI = "invalid_redirect_uri" ErrInvalidClientMetadata = "invalid_client_metadata" )
OAuth 2.0 error codes (RFC 6749 §4.1.2.1 / §5.2, RFC 7591 §3.2.2). These are the wire values returned in the `error` field of a token/registration error response or appended to a redirect URI on an authorization error.
const ( ScopeMCP = "mcp" ScopeMCPRead = "mcp:read" )
MCP scope values understood by the authorization server. These mirror the scopes the MCP handler consumes (internal/mcp/scope.go) — "mcp" grants full tool access, "mcp:read" restricts to non-mutating tools.
const ( // PathProtectedResourceMetadata is the RFC 9728 protected-resource metadata. PathProtectedResourceMetadata = "/.well-known/oauth-protected-resource" // PathAuthorizationServerMetadata is the RFC 8414 AS metadata. PathAuthorizationServerMetadata = "/.well-known/oauth-authorization-server" // PathOpenIDConfiguration is the OIDC discovery alias many clients probe. PathOpenIDConfiguration = "/.well-known/openid-configuration" // PathJWKS publishes the token verification key(s). PathJWKS = "/.well-known/jwks.json" // PathAuthorize is the authorization endpoint (session-gated). PathAuthorize = "/api/v1/oauth/authorize" // PathToken is the token endpoint. PathToken = "/api/v1/oauth/token" // PathRegister is the RFC 7591 dynamic client registration endpoint. PathRegister = "/api/v1/oauth/register" // PathRevoke is the RFC 7009 token revocation endpoint. PathRevoke = "/api/v1/oauth/revoke" // PathMCP is the protected MCP resource. PathMCP = "/api/v1/mcp" )
Discovery document paths (all public, served at the site root rather than under /api/v1 so standard MCP clients find them where the specs require).
const ( GrantAuthorizationCode = "authorization_code" GrantRefreshToken = "refresh_token" CodeChallengeMethodS256 = "S256" // ResponseTypeCode is the only supported response_type (OAuth 2.1). ResponseTypeCode = "code" // AuthMethodNone marks a public client (no client secret). AuthMethodNone = "none" // AuthMethodSecretPost marks a confidential client authenticating with a // secret in the token-request body. AuthMethodSecretPost = "client_secret_post" )
Grant types, response types, PKCE method, and token-endpoint auth-method constants shared across the package. These are OAuth wire values.
Variables ¶
This section is empty.
Functions ¶
func IsLoopbackRedirectURI ¶
IsLoopbackRedirectURI reports whether a redirect URI is a permitted native loopback redirect (RFC 8252 §7.3): an http URL whose host is the IPv4 or IPv6 loopback address or the literal "localhost", on any port. Native MCP clients (Claude Desktop, mcp-remote) spin up an ephemeral loopback listener and cannot pre-register an exact port, so the port is intentionally not matched.
Everything else over http is rejected — plaintext http to a non-loopback host would leak the authorization code in transit.
func IsValidRedirectURI ¶
IsValidRedirectURI reports whether a redirect URI is acceptable to register or to use at /authorize when no exact allow-list match is required:
- loopback http (native clients), or
- https with a host (registered web clients).
A bare http non-loopback URI, a missing host, or a fragment (forbidden by RFC 6749 §3.1.2) is rejected.
func ParseScopes ¶
ParseScopes splits a space-delimited scope string (RFC 6749 §3.3) into a slice, dropping empty entries.
func RedirectURIAllowed ¶
RedirectURIAllowed reports whether requestedURI is permitted for a client whose registered redirect URIs are registered. Registered web URIs require an exact string match (no substring / prefix matching — that is a classic open-redirect bug). Loopback URIs match a registered loopback entry ignoring only the port, since the native client's port is ephemeral.
func ScopesValid ¶
ScopesValid reports whether every requested scope is a recognized MCP scope. An empty request defaults to the full "mcp" scope at the call site, so this only validates explicitly-requested scopes.
func TokenHasResourceAudience ¶
TokenHasResourceAudience reports whether the credential is allowed to act on the given resource based on its audience binding (RFC 8707).
Back-compat is the whole point of the empty-audience case: PATs and legacy session JWTs carry no `aud` claim and must keep working — they pass the audience gate and are governed solely by the existing scope check. An OAuth access token, by contrast, is minted with `aud` = the MCP resource, so it is accepted here only when its audience list contains that exact resource. This stops a token issued for one resource from being replayed at another.
func VerifyPKCE ¶
VerifyPKCE checks an RFC 7636 code_verifier against a stored S256 code_challenge. Only the S256 method is supported (OAuth 2.1 forbids the `plain` method); callers must reject any other method before reaching here.
The verifier is hashed with SHA-256 and base64url-encoded (no padding); the result is compared to the stored challenge in constant time so a timing side-channel cannot be used to forge a matching verifier.
Types ¶
type AuthCodeGrant ¶
type AuthCodeGrant struct {
ClientID string
UserUID string
OrgUID string
OrgSlug string
RedirectURI string
Scope string
Resource string
CodeChallenge string
CodeChallengeMethod string
}
AuthCodeGrant captures everything an authorization code binds together. The token endpoint replays these bindings (client, redirect, PKCE, resource) to stop a stolen code from being redeemed under different parameters.
type AuthorizationServerMetadata ¶
type AuthorizationServerMetadata struct {
Issuer string `json:"issuer"`
AuthorizationEndpoint string `json:"authorization_endpoint"`
TokenEndpoint string `json:"token_endpoint"`
RegistrationEndpoint string `json:"registration_endpoint"`
RevocationEndpoint string `json:"revocation_endpoint"`
JWKSURI string `json:"jwks_uri"`
ScopesSupported []string `json:"scopes_supported"`
ResponseTypesSupported []string `json:"response_types_supported"`
GrantTypesSupported []string `json:"grant_types_supported"`
CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"`
TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported"`
}
AuthorizationServerMetadata is the RFC 8414 document served at /.well-known/oauth-authorization-server (and aliased at /.well-known/openid-configuration).
type Handler ¶
type Handler struct {
base.HandlerBase
// contains filtered or unexported fields
}
Handler exposes the OAuth 2.1 authorization-server HTTP surface: discovery documents, the authorization and token endpoints, and dynamic client registration. It is constructed once and mounted by the app server.
func NewHandler ¶
NewHandler builds the OAuth handler.
func (*Handler) ApproveAuthorize ¶
ApproveAuthorize is the POST authorization endpoint hit when the user approves the consent screen. It re-validates the request, requires a session, mints a single-use code bound to the grant, and redirects to the client's redirect_uri with code + state.
func (*Handler) AuthorizationServerMetadata ¶
AuthorizationServerMetadata serves the RFC 8414 document (and its OIDC alias). Public, no auth.
func (*Handler) Authorize ¶
Authorize is the GET authorization endpoint. It validates the request, then either redirects to the dashboard login (no session) or to the dashboard consent screen (valid session). The actual code is minted by ApproveAuthorize once the user consents.
func (*Handler) ProtectedResourceMetadata ¶
ProtectedResourceMetadata serves the RFC 9728 document. Public, no auth.
func (*Handler) Register ¶
Register is the RFC 7591 dynamic client registration endpoint. Native MCP clients register as public clients (token_endpoint_auth_method=none) with PKCE and loopback redirect URIs; a client that asks for a secret-based auth method is treated as confidential and gets a generated secret. All redirect URIs are validated (loopback http or https only) before persistence so the issuer can never be turned into an open redirector.
func (*Handler) Revoke ¶
Revoke is the RFC 7009 token revocation endpoint. A client posts a form-encoded `token` (the refresh token, plus an optional `token_type_hint`) and its `client_id`; the grant backing that token is soft-deleted only when it is an OAuth refresh grant bound to the same client (see Service.RevokeGrant).
Per RFC 7009 §2.2 the response is ALWAYS 200 with an empty body — for unknown, expired, already-revoked, other-client, or wrong-type tokens alike — so the endpoint can't be used as an oracle to probe token validity. It uses the same client-authentication model as /token: client_id binding, no client-secret verification.
type JWK ¶
type JWK struct {
KeyType string `json:"kty"`
Use string `json:"use,omitempty"`
Algorithm string `json:"alg,omitempty"`
KeyID string `json:"kid,omitempty"`
}
JWK is a single JSON Web Key (RFC 7517). Only the public-safe fields are modeled; we never emit symmetric key material.
type Metadata ¶
type Metadata struct {
// Issuer is the OAuth issuer identifier — SolidPing's own base URL.
Issuer string
}
Metadata centralizes the issuer-derived URLs every handler needs. It is built once per request from cfg.Server.BaseURL so a runtime base-URL override (via the system-parameters overlay) is always reflected.
func NewMetadata ¶
NewMetadata builds a Metadata from the resolved public base URL. A trailing slash on baseURL is trimmed so concatenated paths never double up.
func (Metadata) AuthorizationEndpoint ¶
AuthorizationEndpoint returns the absolute /authorize URL.
func (Metadata) BuildAuthorizationServerMetadata ¶
func (m Metadata) BuildAuthorizationServerMetadata() AuthorizationServerMetadata
BuildAuthorizationServerMetadata assembles the RFC 8414 document. PKCE is S256-only and the implicit grant is deliberately absent (OAuth 2.1).
func (Metadata) BuildProtectedResourceMetadata ¶
func (m Metadata) BuildProtectedResourceMetadata() ProtectedResourceMetadata
BuildProtectedResourceMetadata assembles the RFC 9728 document.
func (Metadata) ProtectedResourceMetadataURL ¶
ProtectedResourceMetadataURL returns the absolute RFC 9728 metadata URL, the value advertised in the WWW-Authenticate resource_metadata pointer.
func (Metadata) RegistrationEndpoint ¶
RegistrationEndpoint returns the absolute /register URL.
func (Metadata) ResourceURL ¶
ResourceURL returns the canonical MCP resource identifier used as the token audience and advertised in the protected-resource metadata.
func (Metadata) RevocationEndpoint ¶
RevocationEndpoint returns the absolute RFC 7009 /revoke URL.
func (Metadata) TokenEndpoint ¶
TokenEndpoint returns the absolute /token URL.
type ProtectedResourceMetadata ¶
type ProtectedResourceMetadata struct {
Resource string `json:"resource"`
AuthorizationServers []string `json:"authorization_servers"`
ScopesSupported []string `json:"scopes_supported"`
BearerMethods []string `json:"bearer_methods_supported"`
}
ProtectedResourceMetadata is the RFC 9728 document served at /.well-known/oauth-protected-resource. It tells a client which authorization server(s) issue tokens for this resource and which scopes it understands.
type Service ¶
type Service struct {
// contains filtered or unexported fields
}
Service holds the business logic for the embedded OAuth 2.1 authorization server: client validation, authorization-code issuance/redemption, and token minting (delegated to the auth service for JWT signing).
func NewService ¶
NewService builds the OAuth service. authSvc is reused for JWT signing and refresh-token semantics so OAuth-issued credentials are indistinguishable from session credentials downstream.
func (*Service) ExchangeAuthCode ¶
func (s *Service) ExchangeAuthCode( ctx context.Context, code, clientID, redirectURI, codeVerifier string, ) (*TokenResult, error)
ExchangeAuthCode redeems an authorization code for an access + refresh token. It enforces, in order: the code exists, is unexpired, and is consumed exactly once (atomic compare-and-set against the underlying state_entries row); the redirect_uri and client_id match the binding; and the PKCE code_verifier matches the stored S256 challenge. Any failure returns errInvalidGrant / errPKCEFailed without minting a token.
func (*Service) ExchangeRefreshToken ¶
func (s *Service) ExchangeRefreshToken( ctx context.Context, refreshToken, clientID string, ) (*TokenResult, error)
ExchangeRefreshToken rotates a refresh grant: it validates the presented token is active and unexpired, atomically revokes it, and issues a fresh access + refresh pair. Grants are user_tokens rows (type oauth_refresh, revocation = soft delete); because the revoking soft-delete is an atomic compare-and-set, a concurrent or replayed use of the same refresh token loses the race and is rejected — the rotation invalidates the prior token.
func (*Service) IssueAuthCode ¶
IssueAuthCode mints a single-use, short-lived authorization code bound to the grant, persisted in the generic state_entries store rather than a dedicated table. The returned code is the opaque value handed back to the client via the redirect; it embeds the organization uid as a prefix (authCodeSep separated) because the token endpoint that redeems it receives no org context of its own — the prefix is how redemption resolves the org-scoped lookup. The rest of the grant (client, redirect, PKCE, scope, resource, user) travels in the entry's JSON value.
func (*Service) RegisterClient ¶
func (s *Service) RegisterClient( ctx context.Context, name string, redirectURIs, grantTypes, scopes []string, isPublic bool, ) (*models.OAuthClient, string, error)
RegisterClient creates a new OAuth client (RFC 7591). Public clients (native / loopback, PKCE) get no secret; confidential clients get a generated secret returned once in the registration response. The caller has already validated the redirect URIs.
func (*Service) RevokeGrant ¶
RevokeGrant implements RFC 7009 token revocation for an OAuth refresh grant. It soft-deletes the oauth_refresh user_tokens row backing refreshToken, but ONLY when that row's client_id binding equals the presented (non-empty) clientID — a client may revoke only grants issued to itself.
Every "nothing to do" outcome is a silent no-op by design, so the endpoint can never be used as an oracle to probe token validity (RFC 7009 mandates a 200 either way): an unknown or already-revoked token, an expired grant, a PAT or session refresh token (wrong type — each is torn down through its own surface), and a grant bound to a different client_id (hence a different client/user) all leave the store untouched.
The returned bool reports whether a live row was actually deleted; it exists for tests and callers that want the fact — the HTTP handler ignores it and always answers 200. A non-nil error is a genuine backend failure, never a "token not found" signal.