mirror of
https://github.com/juanfont/headscale.git
synced 2026-07-16 13:00:44 +09:00
179 lines
5.7 KiB
Go
179 lines
5.7 KiB
Go
package apiv2
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/juanfont/headscale/hscontrol/scope"
|
|
"github.com/juanfont/headscale/hscontrol/types"
|
|
)
|
|
|
|
// accessTokenTTL is the fixed lifetime of a minted access token, matching
|
|
// Tailscale's non-configurable one hour.
|
|
const accessTokenTTL = time.Hour
|
|
|
|
// registerOAuthToken mounts POST /api/v2/oauth/token on the router. It is a plain
|
|
// handler, not a Huma operation: it consumes application/x-www-form-urlencoded
|
|
// and emits RFC 6749 OAuth2 error bodies ({"error","error_description"}), neither
|
|
// of which fits Huma's JSON-in / Tailscale-error-out machinery.
|
|
// ponytail: one bespoke OAuth endpoint isn't worth bending Huma around.
|
|
func registerOAuthToken(router chi.Router, b Backend) {
|
|
router.Post("/api/v2/oauth/token", oauthTokenHandler(b))
|
|
}
|
|
|
|
// tokenResponse is the OAuth 2.0 client-credentials success body.
|
|
type tokenResponse struct {
|
|
AccessToken string `json:"access_token"`
|
|
TokenType string `json:"token_type"`
|
|
ExpiresIn int `json:"expires_in"`
|
|
Scope string `json:"scope,omitempty"`
|
|
}
|
|
|
|
// oauthTokenHandler implements the client-credentials grant (RFC 6749 §4.4):
|
|
// authenticate the client, optionally narrow the granted scopes/tags, and mint a
|
|
// short-lived bearer token.
|
|
func oauthTokenHandler(b Backend) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
err := r.ParseForm()
|
|
if err != nil {
|
|
writeOAuthError(w, http.StatusBadRequest, "invalid_request", "could not parse request body")
|
|
|
|
return
|
|
}
|
|
|
|
// grant_type defaults to client_credentials: Tailscale's documented curl
|
|
// omits it, and the x/oauth2 client always sends it.
|
|
if gt := r.PostForm.Get("grant_type"); gt != "" && gt != "client_credentials" {
|
|
writeOAuthError(w, http.StatusBadRequest, "unsupported_grant_type",
|
|
"only the client_credentials grant is supported")
|
|
|
|
return
|
|
}
|
|
|
|
// Credentials may arrive in the body or as HTTP Basic (the x/oauth2
|
|
// auto-detect probes Basic first). The secret embeds the client id, so a
|
|
// separate client_id is not required.
|
|
secret := r.PostForm.Get("client_secret")
|
|
if secret == "" {
|
|
if _, pass, ok := r.BasicAuth(); ok {
|
|
secret = pass
|
|
}
|
|
}
|
|
|
|
if secret == "" {
|
|
writeOAuthError(w, http.StatusUnauthorized, "invalid_client", "missing client credentials")
|
|
|
|
return
|
|
}
|
|
|
|
client, err := b.State.AuthenticateOAuthClient(secret)
|
|
if err != nil {
|
|
writeOAuthError(w, http.StatusUnauthorized, "invalid_client", "invalid client credentials")
|
|
|
|
return
|
|
}
|
|
|
|
// Optional space-delimited scope/tags narrow the token to a subset of the
|
|
// client's grant.
|
|
scopes, badScope, ok := narrowScopes(client.Scopes, strings.Fields(r.PostForm.Get("scope")))
|
|
if !ok {
|
|
writeOAuthError(w, http.StatusBadRequest, "invalid_scope",
|
|
"scope "+badScope+" is not granted to this client")
|
|
|
|
return
|
|
}
|
|
|
|
tags, badTag, ok := narrowTags(client, strings.Fields(r.PostForm.Get("tags")))
|
|
if !ok {
|
|
writeOAuthError(w, http.StatusBadRequest, "invalid_target",
|
|
"tag "+badTag+" is not granted to this client")
|
|
|
|
return
|
|
}
|
|
|
|
expiry := time.Now().Add(accessTokenTTL)
|
|
|
|
tokenStr, _, err := b.State.MintAccessToken(client.ClientID, scopes, tags, &expiry)
|
|
if err != nil {
|
|
writeOAuthError(w, http.StatusInternalServerError, "server_error", "could not mint access token")
|
|
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, tokenResponse{
|
|
AccessToken: tokenStr,
|
|
TokenType: "Bearer",
|
|
ExpiresIn: int(accessTokenTTL.Seconds()),
|
|
Scope: strings.Join(scopes, " "),
|
|
})
|
|
}
|
|
}
|
|
|
|
// narrowScopes returns the requested scopes if each is granted by the client (an
|
|
// empty request means "the client's full grant"), otherwise the offending scope
|
|
// and false. A client holding a broad scope (e.g. "all") may mint a token limited
|
|
// to a narrower one.
|
|
func narrowScopes(granted, requested []string) ([]string, string, bool) {
|
|
if len(requested) == 0 {
|
|
return granted, "", true
|
|
}
|
|
|
|
for _, req := range requested {
|
|
if !scope.Grants(scope.Parse(granted), scope.Scope(req)) {
|
|
return nil, req, false
|
|
}
|
|
}
|
|
|
|
return requested, "", true
|
|
}
|
|
|
|
// narrowTags returns the requested tags if each is within the client's grant (an
|
|
// empty request means "the client's full tag set"), otherwise the offending tag
|
|
// and false. A client with the "all" scope may assign any tag, matching Tailscale.
|
|
func narrowTags(client *types.OAuthClient, requested []string) ([]string, string, bool) {
|
|
if len(requested) == 0 {
|
|
return client.Tags, "", true
|
|
}
|
|
|
|
allScope := slices.Contains(client.Scopes, string(scope.All))
|
|
|
|
for _, req := range requested {
|
|
// Reject malformed tags at the trust boundary. A client's own tags are
|
|
// validated at creation, so this only matters for an "all"-scope client,
|
|
// whose tags would otherwise skip the membership check below and flow
|
|
// unvalidated into auth-key creation.
|
|
if !strings.HasPrefix(req, "tag:") {
|
|
return nil, req, false
|
|
}
|
|
|
|
if !allScope && !slices.Contains(client.Tags, req) {
|
|
return nil, req, false
|
|
}
|
|
}
|
|
|
|
return requested, "", true
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
// Token responses carry bearer credentials; RFC 6749 §5.1 forbids caching
|
|
// them. writeJSON serves only the token endpoint, so set it unconditionally.
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
w.Header().Set("Pragma", "no-cache")
|
|
w.WriteHeader(status)
|
|
_ = json.NewEncoder(w).Encode(v) //nolint:errchkjson // best-effort response write of a known-safe value
|
|
}
|
|
|
|
// writeOAuthError emits an RFC 6749 §5.2 error body, which the x/oauth2 client
|
|
// parses into its RetrieveError.
|
|
func writeOAuthError(w http.ResponseWriter, status int, code, desc string) {
|
|
writeJSON(w, status, map[string]string{
|
|
"error": code,
|
|
"error_description": desc,
|
|
})
|
|
}
|