principalFromOAuthToken validates an ate2a_-prefixed bearer via fosite's IntrospectToken (which derives the signature using the same strategy that issued the token, looks up the row via our Storage, and checks revoked/expired) and resolves it to a SCOPED principal. The granted OAuth scope is author
(r *http.Request, bearer string)
| 743 | // Every failure wraps errOAuthBearerInvalid so the response layer reliably |
| 744 | // classifies these as OAuth-bearer rejections and emits the OAuth challenge. |
| 745 | func (a *API) principalFromOAuthToken(r *http.Request, bearer string) (*identity.Principal, error) { |
| 746 | if a.oauthProvider == nil { |
| 747 | // OAuth not enabled on this deployment. Fail closed rather |
| 748 | // than fall through to the API-key path (which would compare |
| 749 | // the ate2a_ token against the api_keys hash and miss — a |
| 750 | // slower path to the same 401, with a less actionable log). |
| 751 | return nil, fmt.Errorf("%w: provider not configured", errOAuthBearerInvalid) |
| 752 | } |
| 753 | session := &oauth.Session{} |
| 754 | tu, ar, err := a.oauthProvider.IntrospectToken(r.Context(), bearer, fosite.AccessToken, session) |
| 755 | if err != nil { |
| 756 | // Preserve fosite's typed error via %w so writeAuthError can |
| 757 | // errors.Is(...) against fosite.ErrTokenExpired below. |
| 758 | return nil, fmt.Errorf("%w: %w", errOAuthBearerInvalid, err) |
| 759 | } |
| 760 | // Defense in depth: fosite v0.49's IntrospectToken with |
| 761 | // tokenUse=AccessToken doesn't HARD-reject a refresh-token row — |
| 762 | // it falls back to refresh-token validation if access fails. We |
| 763 | // rely on table separation in storage to keep them disjoint, but |
| 764 | // an explicit check at the seam means a future fosite/storage |
| 765 | // refactor can't silently break the type guard. |
| 766 | if tu != fosite.AccessToken { |
| 767 | return nil, fmt.Errorf("%w: token is not an access token (got %q)", errOAuthBearerInvalid, tu) |
| 768 | } |
| 769 | // Trust the session, not the request — the session was loaded |
| 770 | // from the DB row fosite hydrated. |
| 771 | sess, ok := ar.GetSession().(*oauth.Session) |
| 772 | if !ok || sess.UserID == "" { |
| 773 | return nil, fmt.Errorf("%w: session missing user_id", errOAuthBearerInvalid) |
| 774 | } |
| 775 | u, err := a.store.GetUserByID(r.Context(), sess.UserID) |
| 776 | if err != nil { |
| 777 | // Wrap so writeAuthError emits the OAuth challenge instead of |
| 778 | // a bare 401 (the bearer that got us here was valid; the user |
| 779 | // row vanished out from under it). |
| 780 | return nil, fmt.Errorf("%w: user lookup: %v", errOAuthBearerInvalid, err) |
| 781 | } |
| 782 | |
| 783 | granted := ar.GetGrantedScopes() |
| 784 | switch { |
| 785 | case granted.Has(identity.ScopeAccount): |
| 786 | return &identity.Principal{User: u, Scope: identity.ScopeAccount}, nil |
| 787 | case granted.Has(identity.ScopeAgent): |
| 788 | // Confine to the consent-bound agent. An agent-scoped token with no |
| 789 | // bound agent is malformed — reject rather than fall back to anything |
| 790 | // broader. |
| 791 | if sess.AgentEmail == "" { |
| 792 | return nil, fmt.Errorf("%w: agent-scoped token has no bound agent", errOAuthBearerInvalid) |
| 793 | } |
| 794 | ag, err := a.store.GetAgentByEmail(r.Context(), identity.NormalizeEmail(sess.AgentEmail)) |
| 795 | if err != nil || ag == nil { |
| 796 | return nil, fmt.Errorf("%w: bound agent not found", errOAuthBearerInvalid) |
| 797 | } |
| 798 | // Ownership re-check: the bound agent must still belong to the token's |
| 799 | // user (defends against an agent reassigned/deleted-and-recreated under |
| 800 | // a different owner after consent). |
| 801 | if ag.UserID != u.ID { |
| 802 | return nil, fmt.Errorf("%w: bound agent not owned by token user", errOAuthBearerInvalid) |
no test coverage detected