{"record":{"id":"db1e8ce18cbc913f","repo":"juanfont/headscale","slug":"saving-oauth-access-token-w","errorCode":null,"errorMessage":"saving oauth access token: %w","messagePattern":"saving oauth access token: %w","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"hscontrol/db/oauth.go","lineNumber":318,"sourceCode":"\n\t// Mint inside a transaction that re-checks the client still exists and is\n\t// not revoked, so a mint cannot complete against a client being deleted.\n\terr = hsdb.Write(func(tx *gorm.DB) error {\n\t\tvar client types.OAuthClient\n\n\t\terr := tx.First(&client, \"client_id = ?\", clientID).Error\n\t\tif err != nil {\n\t\t\treturn ErrOAuthClientNotFound\n\t\t}\n\n\t\tif client.Revoked != nil {\n\t\t\treturn ErrOAuthClientRevoked\n\t\t}\n\n\t\treturn tx.Save(&token).Error\n\t})\n\tif err != nil {\n\t\treturn \"\", nil, fmt.Errorf(\"saving oauth access token: %w\", err)\n\t}\n\n\treturn tokenStr, &token, nil\n}\n\n// AuthenticateAccessToken validates a presented bearer token and returns the\n// matching, unexpired [types.OAuthAccessToken] (carrying its granted scopes and\n// tags). A non-nil error means the token is missing, malformed, or expired.\nfunc (hsdb *HSDatabase) AuthenticateAccessToken(tokenStr string) (*types.OAuthAccessToken, error) {\n\tif tokenStr == \"\" {\n\t\treturn nil, ErrAccessTokenFailedToParse\n\t}\n\n\t_, rest, found := strings.Cut(tokenStr, types.AccessTokenPrefix)\n\tif !found {\n\t\treturn nil, ErrAccessTokenFailedToParse\n\t}\n","sourceCodeStart":300,"sourceCodeEnd":336,"githubUrl":"https://github.com/juanfont/headscale/blob/565fd254d06c4c7f9a8cad1714a43445c79ba420/hscontrol/db/oauth.go#L300-L336","documentation":"CreateAccessToken saves the new token row in a transaction that also verifies the issuing client exists and is unrevoked. This wrapper can therefore carry three distinct failures: a DB error on First (availability), ErrOAuthClientNotFound/ErrOAuthClientRevoked (client deleted or revoked in a race), or a DB error on Save (constraint/connectivity). Unwrap to distinguish; the token string is not returned on failure.","triggerScenarios":"Client revoked between the caller's check and token creation; unique-constraint hit on token prefix (astronomically rare); DB lock/timeout.","commonSituations":"Concurrent revoke + token mint from different admin sessions; automations caching client state across revocation.","solutions":["Unwrap: errors.Is(err, db.ErrOAuthClientNotFound) or ErrOAuthClientRevoked means re-authenticate/re-create the client","For DB-level causes, fix availability and retry minting","Serialize revocation and minting through one admin path"],"exampleFix":"// before\n_, tok, err := hsdb.CreateAccessToken(clientID, ...)\nif err != nil {\n\treturn err\n}\n\n// after\n_, tok, err := hsdb.CreateAccessToken(clientID, ...)\nif errors.Is(err, db.ErrOAuthClientNotFound) || errors.Is(err, db.ErrOAuthClientRevoked) {\n\treturn fmt.Errorf(\"client %s revoked or deleted; re-create it\", clientID)\n}","handlingStrategy":"try-catch","validationCode":"// Confirm client exists and is unrevoked before minting\nif c, err := hsdb.GetOAuthClientByClientID(clientID); err != nil || c.Revoked != nil {\n\treturn fmt.Errorf(\"client %s unavailable\", clientID)\n}","typeGuard":"func isClientStateError(err error) bool {\n\treturn errors.Is(err, db.ErrOAuthClientNotFound) ||\n\t\terrors.Is(err, db.ErrOAuthClientRevoked)\n}","tryCatchPattern":"_, tok, err := hsdb.CreateAccessToken(clientID, ...)\nif err != nil {\n\tif isClientStateError(err) {\n\t\t// client revoked mid-flight: surface actionable error\n\t\treturn errClientRevoked\n\t}\n\tif isTransientDBError(err) {\n\t\t// safe to retry; token minted only on success\n\t}\n\treturn err\n}","preventionTips":["Never cache 'client is alive' across long-lived sessions","Discard token strings from failed calls","Treat revoke and mint as mutually exclusive admin operations"],"tags":["go","oauth","access-token","race-condition","database"],"backgroundTag":null,"analyzedSha":"565fd254d06c4c7f9a8cad1714a43445c79ba420","analyzedAt":"2026-08-15T13:12:30.133Z","schemaVersion":2},"datasetVersion":"2026-08-15T22:17:37.221Z"}