juanfont/headscale · error · gorm.ErrRecordNotFound

ErrOAuthClientNotFound

ErrOAuthClientNotFound

Error message

oauth client not found: %w

What it means

ErrOAuthClientNotFound wraps gorm.ErrRecordNotFound: a lookup by client_id (derived from the middle segment of the presented secret) matched no row. It is a sentinel error — callers are expected to test with errors.Is and map it to a 404/401 rather than a 500. A client deleted or revoked outright (hard delete) also yields this.

Source

Thrown at hscontrol/db/oauth.go:37

)

const (
	// OAuth client secret: hskey-client-<clientID(12)>-<secret(64)>. The clientID
	// is the public, indexed lookup key (the analogue of an API key's prefix) and
	// is embedded in the secret so the token endpoint can derive it. The prefix
	// itself lives in the types package ([types.OAuthClientPrefix]).
	oauthClientIDLength     = 12
	oauthClientSecretLength = 64

	// OAuth access token: hskey-oauthtok-<prefix(12)>-<secret(64)>. The distinct
	// prefix (vs hskey-api- admin keys, [types.AccessTokenPrefix]) lets the auth
	// middleware dispatch a scoped token from an all-access admin key alone.
	accessTokenPrefixLength = 12
	accessTokenSecretLength = 64
)

var (
	ErrOAuthClientNotFound      = fmt.Errorf("oauth client not found: %w", gorm.ErrRecordNotFound)
	ErrOAuthClientFailedToParse = errors.New("failed to parse oauth client secret")
	ErrOAuthClientRevoked       = errors.New("oauth client revoked")

	ErrAccessTokenNotFound      = fmt.Errorf("oauth access token not found: %w", gorm.ErrRecordNotFound)
	ErrAccessTokenFailedToParse = errors.New("failed to parse oauth access token")
	ErrAccessTokenExpired       = errors.New("oauth access token expired")
	ErrAccessTokenClientRevoked = errors.New("oauth access token issuing client revoked or deleted")

	errSecretHashMalformed = errors.New("malformed secret hash")
	errSecretMismatch      = errors.New("secret does not match hash")
)

// Argon2id parameters, OWASP's minimum recommendation (19 MiB, 2 iterations, 1
// lane). They are encoded into every stored hash, so raising them later still
// verifies credentials stored under the old cost.
const (
	argon2Time    = 2
	argon2Memory  = 19 * 1024

View on GitHub (pinned to 565fd254d0)

Solutions

  1. Check errors.Is(err, db.ErrOAuthClientNotFound) or errors.Is(err, gorm.ErrRecordNotFound) and treat as invalid credentials (404/401)
  2. Regenerate the client secret and update the consuming application
  3. Verify the secret was stored whole (correct length, no whitespace/newline)

Example fix

// before
if err != nil {
	log.Fatal(err) // surfaces as opaque failure
}

// after
if errors.Is(err, db.ErrOAuthClientNotFound) {
	http.Error(w, "unknown oauth client", http.StatusUnauthorized)
	return
}
Defensive patterns

Strategy: try-catch

Validate before calling

// Cheap existence pre-check when you control the flow
if _, err := hsdb.GetOAuthClientByClientID(clientID); err != nil {
	// regenerate secret / tell user client is gone
}

Type guard

func isOAuthClientNotFound(err error) bool {
	return errors.Is(err, db.ErrOAuthClientNotFound) ||
		errors.Is(err, gorm.ErrRecordNotFound)
}

Try / catch

if _, err := hsdb.AuthenticateOAuthClient(secret); err != nil {
	if errors.Is(err, db.ErrOAuthClientNotFound) {
		return ErrUnauthorizedClientUnknown
	}
	return err
}

Prevention

When it happens

Trigger: Authenticating with a secret whose 12-char prefix does not exist; client was deleted via RevokeOAuthClient; secret truncated or corrupted so the derived client_id is wrong.

Common situations: Scripts holding stale credentials after client rotation; copy-paste losing characters of the long secret; environment variable truncation at 64-char secrets.

Related errors


AI-assisted analysis of juanfont/headscale@565fd254d0 (2026-08-15). Data as JSON: /api/errors/d4ba1df2917aec77. Report an issue: GitHub.