mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-06 08:01:24 +00:00
339 lines
12 KiB
Go
339 lines
12 KiB
Go
package oauth
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/warmbly/warmbly/internal/models"
|
|
)
|
|
|
|
// AuthorizeRequest holds the (un-trusted) /authorize parameters.
|
|
type AuthorizeRequest struct {
|
|
ResponseType string
|
|
ClientID string
|
|
RedirectURI string
|
|
Scope string
|
|
State string
|
|
CodeChallenge string
|
|
CodeChallengeMethod string
|
|
}
|
|
|
|
// ConsentInfo is what the dashboard consent screen renders: who is asking, for
|
|
// what, and where they'll be sent back.
|
|
type ConsentInfo struct {
|
|
ClientID string `json:"client_id"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
LogoURL string `json:"logo_url"`
|
|
WebsiteURL string `json:"website_url"`
|
|
RedirectURI string `json:"redirect_uri"`
|
|
Scopes []string `json:"scopes"`
|
|
State string `json:"state"`
|
|
}
|
|
|
|
// TokenResponse is the /token success body (RFC 6749 §5.1).
|
|
type TokenResponse struct {
|
|
AccessToken string `json:"access_token"`
|
|
TokenType string `json:"token_type"`
|
|
ExpiresIn int `json:"expires_in"`
|
|
RefreshToken string `json:"refresh_token,omitempty"`
|
|
Scope string `json:"scope"`
|
|
}
|
|
|
|
// AccessClaims is what a validated access token resolves to, for the middleware.
|
|
type AccessClaims struct {
|
|
GrantID uuid.UUID
|
|
ApplicationID uuid.UUID
|
|
OrganizationID uuid.UUID
|
|
UserID uuid.UUID
|
|
Scopes uint64
|
|
}
|
|
|
|
// AuthorizeDetails validates the authorize request and returns consent info, or
|
|
// an *OAuthError describing what's wrong with the client/redirect/scope.
|
|
func (s *Service) AuthorizeDetails(ctx context.Context, req AuthorizeRequest) (*ConsentInfo, error) {
|
|
app, scopes, err := s.validateAuthorize(ctx, req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &ConsentInfo{
|
|
ClientID: app.ClientID,
|
|
Name: app.Name,
|
|
Description: app.Description,
|
|
LogoURL: app.LogoURL,
|
|
WebsiteURL: app.WebsiteURL,
|
|
RedirectURI: req.RedirectURI,
|
|
Scopes: ScopeList(scopes),
|
|
State: req.State,
|
|
}, nil
|
|
}
|
|
|
|
// IssueAuthorizationCode is called after the user approves consent. It re-checks
|
|
// the request, mints a single-use PKCE-bound code for this user+org, and returns
|
|
// the redirect URL (redirect_uri?code=...&state=...) for the browser to follow.
|
|
func (s *Service) IssueAuthorizationCode(ctx context.Context, orgID, userID uuid.UUID, req AuthorizeRequest) (string, error) {
|
|
app, scopes, err := s.validateAuthorize(ctx, req)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
code, err := randomToken(models.OAuthCodePrefix)
|
|
if err != nil {
|
|
return "", errServer("could not mint code")
|
|
}
|
|
method := ""
|
|
if strings.TrimSpace(req.CodeChallenge) != "" {
|
|
method = "S256"
|
|
}
|
|
ac := &models.OAuthAuthorizationCode{
|
|
CodeHash: hashToken(code),
|
|
ApplicationID: app.ID,
|
|
OrganizationID: orgID,
|
|
UserID: userID,
|
|
RedirectURI: req.RedirectURI,
|
|
Scopes: scopes,
|
|
CodeChallenge: req.CodeChallenge,
|
|
CodeChallengeMethod: method,
|
|
ExpiresAt: time.Now().UTC().Add(models.OAuthAuthorizationCodeTTL),
|
|
}
|
|
if err := s.repo.CreateAuthorizationCode(ctx, ac); err != nil {
|
|
return "", errServer("could not store code")
|
|
}
|
|
return buildRedirect(req.RedirectURI, code, req.State), nil
|
|
}
|
|
|
|
// validateAuthorize enforces response_type=code, a known active client, an exact
|
|
// redirect match, mandatory PKCE (S256), and a scope set within the app's grant.
|
|
func (s *Service) validateAuthorize(ctx context.Context, req AuthorizeRequest) (*models.OAuthApplication, uint64, error) {
|
|
if req.ResponseType != "code" {
|
|
return nil, 0, errInvalidRequest("response_type must be 'code'")
|
|
}
|
|
app, err := s.repo.GetApplicationByClientID(ctx, strings.TrimSpace(req.ClientID))
|
|
if err != nil {
|
|
return nil, 0, errServer("client lookup failed")
|
|
}
|
|
if app == nil || app.Status != models.OAuthAppActive {
|
|
return nil, 0, errUnauthorizedClient("unknown or disabled client")
|
|
}
|
|
if !redirectAllowed(app, req.RedirectURI) {
|
|
return nil, 0, errInvalidRequest("redirect_uri does not match a registered URI")
|
|
}
|
|
// Public clients hold no secret, so PKCE is the only thing binding the code to
|
|
// the caller: it is mandatory for them (and S256-only for everyone).
|
|
if app.IsPublic && strings.TrimSpace(req.CodeChallenge) == "" {
|
|
return nil, 0, errInvalidRequest("code_challenge is required for public clients")
|
|
}
|
|
if strings.TrimSpace(req.CodeChallenge) != "" && req.CodeChallengeMethod != "S256" {
|
|
return nil, 0, errInvalidRequest("code_challenge_method must be 'S256'")
|
|
}
|
|
mask, unknown := ParseScopes(req.Scope)
|
|
if len(unknown) > 0 {
|
|
return nil, 0, errInvalidScope("unknown scope: " + strings.Join(unknown, " "))
|
|
}
|
|
if mask == 0 {
|
|
mask = app.Scopes // default to the app's full registered scope set
|
|
}
|
|
if mask&^app.Scopes != 0 {
|
|
return nil, 0, errInvalidScope("requested scope exceeds what this app may request")
|
|
}
|
|
return app, mask, nil
|
|
}
|
|
|
|
// ExchangeCode handles grant_type=authorization_code: authenticate the client,
|
|
// atomically consume the code, verify redirect + PKCE, and issue tokens.
|
|
func (s *Service) ExchangeCode(ctx context.Context, clientID, clientSecret, code, redirectURI, codeVerifier string) (*TokenResponse, error) {
|
|
app, err := s.authenticateClient(ctx, clientID, clientSecret)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ac, err := s.repo.TakeAuthorizationCode(ctx, hashToken(code))
|
|
if err != nil {
|
|
return nil, errServer("code lookup failed")
|
|
}
|
|
if ac == nil {
|
|
return nil, errInvalidGrant("invalid or expired authorization code")
|
|
}
|
|
if ac.ApplicationID != app.ID {
|
|
return nil, errInvalidGrant("authorization code was issued to a different client")
|
|
}
|
|
if ac.RedirectURI != redirectURI {
|
|
return nil, errInvalidGrant("redirect_uri does not match the authorization request")
|
|
}
|
|
// A public client's code must carry a PKCE challenge (validateAuthorize enforces
|
|
// this at issue time; re-check here so a code can never be exchanged without it).
|
|
if app.IsPublic && ac.CodeChallenge == "" {
|
|
return nil, errInvalidGrant("PKCE is required for public clients")
|
|
}
|
|
// If the authorize request bound a PKCE challenge, the verifier must match.
|
|
if ac.CodeChallenge != "" && !verifyPKCE(codeVerifier, ac.CodeChallenge) {
|
|
return nil, errInvalidGrant("PKCE verification failed")
|
|
}
|
|
return s.issueGrant(ctx, app.ID, ac.OrganizationID, ac.UserID, ac.Scopes)
|
|
}
|
|
|
|
// RefreshToken handles grant_type=refresh_token with rotation: the presented
|
|
// refresh token is consumed and a fresh access+refresh pair issued.
|
|
func (s *Service) RefreshToken(ctx context.Context, clientID, clientSecret, refreshToken string) (*TokenResponse, error) {
|
|
app, err := s.authenticateClient(ctx, clientID, clientSecret)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
g, err := s.repo.GetGrantByRefreshTokenHash(ctx, hashToken(refreshToken))
|
|
if err != nil {
|
|
return nil, errServer("token lookup failed")
|
|
}
|
|
if g == nil || g.RevokedAt != nil || g.ApplicationID != app.ID {
|
|
return nil, errInvalidGrant("invalid refresh token")
|
|
}
|
|
if g.RefreshExpiresAt != nil && g.RefreshExpiresAt.Before(time.Now().UTC()) {
|
|
return nil, errInvalidGrant("refresh token expired")
|
|
}
|
|
access, err := randomToken(models.OAuthAccessTokenPrefix)
|
|
if err != nil {
|
|
return nil, errServer("could not mint token")
|
|
}
|
|
refresh, err := randomToken(models.OAuthRefreshTokenPrefix)
|
|
if err != nil {
|
|
return nil, errServer("could not mint token")
|
|
}
|
|
accessExp := time.Now().UTC().Add(models.OAuthAccessTokenTTL)
|
|
refreshExp := time.Now().UTC().Add(models.OAuthRefreshTokenTTL)
|
|
if err := s.repo.RotateGrantTokens(ctx, g.ID, hashToken(access), hashToken(refresh), accessExp, &refreshExp); err != nil {
|
|
return nil, errServer("could not rotate token")
|
|
}
|
|
return &TokenResponse{
|
|
AccessToken: access,
|
|
TokenType: "Bearer",
|
|
ExpiresIn: int(models.OAuthAccessTokenTTL.Seconds()),
|
|
RefreshToken: refresh,
|
|
Scope: ScopeString(g.Scopes),
|
|
}, nil
|
|
}
|
|
|
|
// RevokeToken revokes the grant behind an access or refresh token (RFC 7009).
|
|
// Per spec it succeeds even for an unknown token.
|
|
func (s *Service) RevokeToken(ctx context.Context, clientID, clientSecret, token string) error {
|
|
app, err := s.authenticateClient(ctx, clientID, clientSecret)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := s.repo.RevokeGrantByTokenHash(ctx, app.ID, hashToken(token)); err != nil {
|
|
return err
|
|
}
|
|
// The org may have lost its last grant — reconcile removes its app endpoint.
|
|
s.ReconcileAppEndpoints(ctx, app.ID)
|
|
return nil
|
|
}
|
|
|
|
// ValidateAccessToken resolves a bearer access token to its grant if the grant
|
|
// is active and unexpired. Used by the request-auth middleware.
|
|
func (s *Service) ValidateAccessToken(ctx context.Context, token string) (*AccessClaims, error) {
|
|
if !strings.HasPrefix(token, models.OAuthAccessTokenPrefix) {
|
|
return nil, fmt.Errorf("not an oauth access token")
|
|
}
|
|
g, err := s.repo.GetGrantByAccessTokenHash(ctx, hashToken(token))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if g == nil || g.RevokedAt != nil || g.AccessExpiresAt.Before(time.Now().UTC()) {
|
|
return nil, fmt.Errorf("invalid or expired access token")
|
|
}
|
|
return &AccessClaims{
|
|
GrantID: g.ID,
|
|
ApplicationID: g.ApplicationID,
|
|
OrganizationID: g.OrganizationID,
|
|
UserID: g.UserID,
|
|
Scopes: g.Scopes,
|
|
}, nil
|
|
}
|
|
|
|
// issueGrant mints and stores a new access+refresh token pair.
|
|
func (s *Service) issueGrant(ctx context.Context, appID, orgID, userID uuid.UUID, scopes uint64) (*TokenResponse, error) {
|
|
access, err := randomToken(models.OAuthAccessTokenPrefix)
|
|
if err != nil {
|
|
return nil, errServer("could not mint token")
|
|
}
|
|
refresh, err := randomToken(models.OAuthRefreshTokenPrefix)
|
|
if err != nil {
|
|
return nil, errServer("could not mint token")
|
|
}
|
|
refreshExp := time.Now().UTC().Add(models.OAuthRefreshTokenTTL)
|
|
g := &models.OAuthAccessGrant{
|
|
ApplicationID: appID,
|
|
OrganizationID: orgID,
|
|
UserID: userID,
|
|
Scopes: scopes,
|
|
AccessTokenHash: hashToken(access),
|
|
RefreshTokenHash: hashToken(refresh),
|
|
AccessExpiresAt: time.Now().UTC().Add(models.OAuthAccessTokenTTL),
|
|
RefreshExpiresAt: &refreshExp,
|
|
}
|
|
if err := s.repo.CreateAccessGrant(ctx, g); err != nil {
|
|
return nil, errServer("could not store grant")
|
|
}
|
|
// Newly-authorized org: materialize the app's webhook endpoint for it (scoped
|
|
// to the granted permissions). Best-effort.
|
|
s.ReconcileAppEndpoints(ctx, appID)
|
|
return &TokenResponse{
|
|
AccessToken: access,
|
|
TokenType: "Bearer",
|
|
ExpiresIn: int(models.OAuthAccessTokenTTL.Seconds()),
|
|
RefreshToken: refresh,
|
|
Scope: ScopeString(scopes),
|
|
}, nil
|
|
}
|
|
|
|
// authenticateClient resolves and authenticates the OAuth client. Confidential
|
|
// clients present a matching secret; public clients (PKCE, no secret — every
|
|
// dynamically-registered MCP client) present none, and their identity is proven
|
|
// by the PKCE verifier at code exchange instead.
|
|
func (s *Service) authenticateClient(ctx context.Context, clientID, clientSecret string) (*models.OAuthApplication, error) {
|
|
app, err := s.repo.GetApplicationByClientID(ctx, strings.TrimSpace(clientID))
|
|
if err != nil {
|
|
return nil, errServer("client lookup failed")
|
|
}
|
|
if app == nil || app.Status != models.OAuthAppActive {
|
|
return nil, errInvalidClient("unknown or disabled client")
|
|
}
|
|
if app.IsPublic {
|
|
return app, nil
|
|
}
|
|
if clientSecret == "" || app.ClientSecretHash == "" || hashToken(clientSecret) != app.ClientSecretHash {
|
|
return nil, errInvalidClient("invalid client credentials")
|
|
}
|
|
return app, nil
|
|
}
|
|
|
|
// redirectAllowed does an exact-string match against the app's registered URIs.
|
|
func redirectAllowed(app *models.OAuthApplication, uri string) bool {
|
|
uri = strings.TrimSpace(uri)
|
|
if uri == "" {
|
|
return false
|
|
}
|
|
for _, r := range app.RedirectURIs {
|
|
if r == uri {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// buildRedirect appends ?code=&state= to the (already-validated) redirect URI.
|
|
func buildRedirect(redirectURI, code, state string) string {
|
|
u, err := url.Parse(redirectURI)
|
|
if err != nil {
|
|
return redirectURI
|
|
}
|
|
q := u.Query()
|
|
q.Set("code", code)
|
|
if state != "" {
|
|
q.Set("state", state)
|
|
}
|
|
u.RawQuery = q.Encode()
|
|
return u.String()
|
|
}
|