{"record":{"id":"7121107ee6166b6c","repo":"juanfont/headscale","slug":"deleting-oauth-access-tokens-w","errorCode":null,"errorMessage":"deleting oauth access tokens: %w","messagePattern":"deleting oauth access tokens: %w","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"hscontrol/db/oauth.go","lineNumber":257,"sourceCode":"\n\terr := hsdb.DB.Find(&clients).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn clients, nil\n}\n\n// RevokeOAuthClient deletes a client and all access tokens it issued. An unknown\n// client id returns [ErrOAuthClientNotFound], so a repeated DELETE is a clean\n// 404. Unlike pre-auth keys (which soft-revoke for node-registration history), an\n// OAuth client has no such history and is removed outright, matching Tailscale.\nfunc (hsdb *HSDatabase) RevokeOAuthClient(clientID string) error {\n\treturn hsdb.Write(func(tx *gorm.DB) error {\n\t\terr := tx.Where(\"client_id = ?\", clientID).\n\t\t\tDelete(&types.OAuthAccessToken{}).Error\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"deleting oauth access tokens: %w\", err)\n\t\t}\n\n\t\tres := tx.Where(\"client_id = ?\", clientID).Delete(&types.OAuthClient{})\n\t\tif res.Error != nil {\n\t\t\treturn res.Error\n\t\t}\n\n\t\tif res.RowsAffected == 0 {\n\t\t\treturn ErrOAuthClientNotFound\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\n// MintAccessToken stores a new [types.OAuthAccessToken] for clientID with the\n// given (already narrowed) scopes/tags and expiration, returning the plaintext\n// token (shown ONCE).","sourceCodeStart":239,"sourceCodeEnd":275,"githubUrl":"https://github.com/juanfont/headscale/blob/565fd254d06c4c7f9a8cad1714a43445c79ba420/hscontrol/db/oauth.go#L239-L275","documentation":"RevokeOAuthClient first deletes all OAuthAccessTokens rows for the client, then the client itself, in one write transaction. This error is the token DELETE failing at the DB level; note it fires before the RowsAffected==0 check, so it is never the 'unknown client' signal — that is ErrOAuthClientNotFound returned later.","triggerScenarios":"DB lock/timeout during revocation; FK constraint from a referencing table not modeled in the delete; connection loss mid-transaction.","commonSituations":"Revoking while many requests authenticate concurrently on SQLite; schema drift after skipped migrations.","solutions":["Unwrap the driver error to identify the failing constraint or lock","Resolve DB contention (single writer, busy_timeout) and retry the revoke","Re-run the DELETE — revocation is idempotent and a repeat returns clean 404 per the doc comment"],"exampleFix":null,"handlingStrategy":"retry","validationCode":null,"typeGuard":null,"tryCatchPattern":"if err := hsdb.RevokeOAuthClient(id); err != nil {\n\tif errors.Is(err, db.ErrOAuthClientNotFound) {\n\t\treturn nil // already revoked; idempotent\n\t}\n\tif isTransientDBError(err) {\n\t\treturn retry(3, func() error { return hsdb.RevokeOAuthClient(id) })\n\t}\n\treturn err\n}","preventionTips":["Remember revocation is idempotent — repeated 404 is success","Run revoke outside peak write load on SQLite"],"tags":["go","oauth","database","revocation","gorm"],"backgroundTag":null,"analyzedSha":"565fd254d06c4c7f9a8cad1714a43445c79ba420","analyzedAt":"2026-08-15T13:12:30.133Z","schemaVersion":2},"datasetVersion":"2026-08-15T22:17:37.221Z"}