{"record":{"id":"d4ba1df2917aec77","repo":"juanfont/headscale","slug":"erroauthclientnotfound","errorCode":"ErrOAuthClientNotFound","errorMessage":"oauth client not found: %w","messagePattern":"oauth client not found: %w","errorType":"error_code","errorClass":"gorm.ErrRecordNotFound","httpStatus":404,"severity":"error","filePath":"hscontrol/db/oauth.go","lineNumber":37,"sourceCode":")\n\nconst (\n\t// OAuth client secret: hskey-client-<clientID(12)>-<secret(64)>. The clientID\n\t// is the public, indexed lookup key (the analogue of an API key's prefix) and\n\t// is embedded in the secret so the token endpoint can derive it. The prefix\n\t// itself lives in the types package ([types.OAuthClientPrefix]).\n\toauthClientIDLength     = 12\n\toauthClientSecretLength = 64\n\n\t// OAuth access token: hskey-oauthtok-<prefix(12)>-<secret(64)>. The distinct\n\t// prefix (vs hskey-api- admin keys, [types.AccessTokenPrefix]) lets the auth\n\t// middleware dispatch a scoped token from an all-access admin key alone.\n\taccessTokenPrefixLength = 12\n\taccessTokenSecretLength = 64\n)\n\nvar (\n\tErrOAuthClientNotFound      = fmt.Errorf(\"oauth client not found: %w\", gorm.ErrRecordNotFound)\n\tErrOAuthClientFailedToParse = errors.New(\"failed to parse oauth client secret\")\n\tErrOAuthClientRevoked       = errors.New(\"oauth client revoked\")\n\n\tErrAccessTokenNotFound      = fmt.Errorf(\"oauth access token not found: %w\", gorm.ErrRecordNotFound)\n\tErrAccessTokenFailedToParse = errors.New(\"failed to parse oauth access token\")\n\tErrAccessTokenExpired       = errors.New(\"oauth access token expired\")\n\tErrAccessTokenClientRevoked = errors.New(\"oauth access token issuing client revoked or deleted\")\n\n\terrSecretHashMalformed = errors.New(\"malformed secret hash\")\n\terrSecretMismatch      = errors.New(\"secret does not match hash\")\n)\n\n// Argon2id parameters, OWASP's minimum recommendation (19 MiB, 2 iterations, 1\n// lane). They are encoded into every stored hash, so raising them later still\n// verifies credentials stored under the old cost.\nconst (\n\targon2Time    = 2\n\targon2Memory  = 19 * 1024","sourceCodeStart":19,"sourceCodeEnd":55,"githubUrl":"https://github.com/juanfont/headscale/blob/565fd254d06c4c7f9a8cad1714a43445c79ba420/hscontrol/db/oauth.go#L19-L55","documentation":"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.","triggerScenarios":"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.","commonSituations":"Scripts holding stale credentials after client rotation; copy-paste losing characters of the long secret; environment variable truncation at 64-char secrets.","solutions":["Check errors.Is(err, db.ErrOAuthClientNotFound) or errors.Is(err, gorm.ErrRecordNotFound) and treat as invalid credentials (404/401)","Regenerate the client secret and update the consuming application","Verify the secret was stored whole (correct length, no whitespace/newline)"],"exampleFix":"// before\nif err != nil {\n\tlog.Fatal(err) // surfaces as opaque failure\n}\n\n// after\nif errors.Is(err, db.ErrOAuthClientNotFound) {\n\thttp.Error(w, \"unknown oauth client\", http.StatusUnauthorized)\n\treturn\n}","handlingStrategy":"try-catch","validationCode":"// Cheap existence pre-check when you control the flow\nif _, err := hsdb.GetOAuthClientByClientID(clientID); err != nil {\n\t// regenerate secret / tell user client is gone\n}","typeGuard":"func isOAuthClientNotFound(err error) bool {\n\treturn errors.Is(err, db.ErrOAuthClientNotFound) ||\n\t\terrors.Is(err, gorm.ErrRecordNotFound)\n}","tryCatchPattern":"if _, err := hsdb.AuthenticateOAuthClient(secret); err != nil {\n\tif errors.Is(err, db.ErrOAuthClientNotFound) {\n\t\treturn ErrUnauthorizedClientUnknown\n\t}\n\treturn err\n}","preventionTips":["Check sentinel errors with errors.Is, never string matching","Rotate secrets through tooling that stores them whole","Handle revoked/deleted clients as 401/404, not 500"],"tags":["go","oauth","authentication","sentinel-error","not-found"],"backgroundTag":null,"analyzedSha":"565fd254d06c4c7f9a8cad1714a43445c79ba420","analyzedAt":"2026-08-15T13:12:30.133Z","schemaVersion":2},"datasetVersion":"2026-08-15T22:17:37.221Z"}