-
Notifications
You must be signed in to change notification settings - Fork 44
feat: PAT authentication chain, token validation, and error handling #1442
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 5 commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
740ae9c
feat: PAT authentication chain, token validation, and error handling
AmanGIT07 250929a
refactor: relocate `errSkip`, improve PAT validation test, and clarif…
AmanGIT07 5af6e2d
chore: add clarification comment for nullable `last_used_at` field in…
AmanGIT07 5107863
refactor: make `UpdateLastUsedAt` call synchronous in PAT validation
AmanGIT07 f1cbf5e
refactor: return error on `UpdateLastUsedAt` failure
AmanGIT07 c7acc95
Merge remote-tracking branch 'origin/main' into feat/pat-client-asser…
AmanGIT07 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,227 @@ | ||
| package authenticate | ||
|
|
||
| import ( | ||
| "context" | ||
| "encoding/base64" | ||
| "fmt" | ||
| "strings" | ||
|
|
||
| "github.com/lestrrat-go/jwx/v2/jwt" | ||
| frontiersession "github.com/raystack/frontier/core/authenticate/session" | ||
| "github.com/raystack/frontier/core/authenticate/token" | ||
| patErrors "github.com/raystack/frontier/core/userpat/errors" | ||
| "github.com/raystack/frontier/internal/bootstrap/schema" | ||
| "github.com/raystack/frontier/pkg/errors" | ||
| "github.com/raystack/frontier/pkg/utils" | ||
| ) | ||
|
|
||
| // AuthenticatorFunc attempts to authenticate a request. | ||
| // Returns (Principal, nil) on success, errSkip if not applicable (try next), | ||
| // or any other error for a terminal authentication failure. | ||
| type AuthenticatorFunc func(ctx context.Context, s *Service) (Principal, error) | ||
|
|
||
| // authenticators maps each ClientAssertion to its authentication function. | ||
| var authenticators = map[ClientAssertion]AuthenticatorFunc{ | ||
| SessionClientAssertion: authenticateWithSession, | ||
| PATClientAssertion: authenticateWithPAT, | ||
| AccessTokenClientAssertion: authenticateWithAccessToken, | ||
| JWTGrantClientAssertion: authenticateWithJWTGrant, | ||
| ClientCredentialsClientAssertion: authenticateWithClientCredentials, | ||
| OpaqueTokenClientAssertion: authenticateWithClientCredentials, | ||
| PassthroughHeaderClientAssertion: authenticateWithPassthroughHeader, | ||
| } | ||
|
|
||
| // authenticateWithSession extracts user from session cookie. | ||
| // Copied from original GetPrincipal session block. | ||
| func authenticateWithSession(ctx context.Context, s *Service) (Principal, error) { | ||
| session, err := s.sessionService.ExtractFromContext(ctx) | ||
| if err == nil && session.IsValid(s.Now()) && utils.IsValidUUID(session.UserID) { | ||
| // userID is a valid uuid | ||
| currentUser, err := s.userService.GetByID(ctx, session.UserID) | ||
| if err != nil { | ||
| s.log.Debug(fmt.Sprintf("unable to get session user by id: %v", err)) | ||
| return Principal{}, err | ||
| } | ||
| return Principal{ | ||
| ID: currentUser.ID, | ||
| Type: schema.UserPrincipal, | ||
| User: ¤tUser, | ||
| }, nil | ||
| } | ||
| if err != nil && !errors.Is(err, frontiersession.ErrNoSession) { | ||
| s.log.Debug(fmt.Sprintf("unable to extract session from context: %v", err)) | ||
| return Principal{}, err | ||
| } | ||
| return Principal{}, errSkip | ||
| } | ||
|
|
||
| // authenticateWithPAT validates a personal access token. | ||
| func authenticateWithPAT(ctx context.Context, s *Service) (Principal, error) { | ||
| value, ok := GetTokenFromContext(ctx) | ||
| if !ok { | ||
| return Principal{}, errSkip | ||
| } | ||
|
|
||
| pat, err := s.userPATService.Validate(ctx, value) | ||
| if err != nil { | ||
| if errors.Is(err, patErrors.ErrInvalidPAT) || errors.Is(err, patErrors.ErrDisabled) { | ||
| return Principal{}, errSkip | ||
| } | ||
| s.log.Debug("PAT validation failed", "err", err) | ||
| return Principal{}, err | ||
| } | ||
|
|
||
| // resolve the owning user so downstream handlers can access principal.User | ||
| currentUser, err := s.userService.GetByID(ctx, pat.UserID) | ||
| if err != nil { | ||
| s.log.Debug("failed to get PAT owner", "err", err) | ||
| return Principal{}, err | ||
| } | ||
|
|
||
| return Principal{ | ||
| ID: pat.ID, | ||
| Type: schema.PATPrincipal, | ||
| PAT: &pat, | ||
| User: ¤tUser, | ||
| }, nil | ||
| } | ||
|
|
||
| // authenticateWithAccessToken validates a Frontier-issued JWT access token. | ||
| // Copied from original GetPrincipal access token block. | ||
| func authenticateWithAccessToken(ctx context.Context, s *Service) (Principal, error) { | ||
| userToken, ok := GetTokenFromContext(ctx) | ||
| if !ok { | ||
| return Principal{}, errSkip | ||
| } | ||
|
|
||
| insecureJWT, err := jwt.ParseInsecure([]byte(userToken)) | ||
| if err != nil { | ||
| // NOTE: in the original code, AccessToken and JWTGrant were in the same if-block, | ||
| // so JWT parse failure fell through to GetByJWT. With separate authenticators, | ||
| // errSkip is required to preserve that behavior. | ||
| s.log.Debug(fmt.Sprintf("unable to parse token: %v", err)) | ||
| return Principal{}, errSkip | ||
| } | ||
|
|
||
| // check type of jwt | ||
| if genClaim, ok := insecureJWT.Get(token.GeneratedClaimKey); ok { | ||
| // jwt generated by frontier using public key | ||
| claimVal, ok := genClaim.(string) | ||
| if !ok || claimVal != token.GeneratedClaimValue { | ||
| s.log.Debug("generated claim value mismatch") | ||
| return Principal{}, errors.ErrUnauthenticated | ||
| } | ||
|
|
||
| // extract user from token if present as its created by frontier | ||
| userID, claims, err := s.internalTokenService.Parse(ctx, []byte(userToken)) | ||
| if err != nil || !utils.IsValidUUID(userID) { | ||
| s.log.Debug("failed to parse as internal token ", "err", err) | ||
| return Principal{}, errors.ErrUnauthenticated | ||
| } | ||
|
|
||
| // userID is a valid uuid | ||
| if claims[token.SubTypeClaimsKey] == schema.ServiceUserPrincipal { | ||
| currentUser, err := s.serviceUserService.Get(ctx, userID) | ||
| if err != nil { | ||
| s.log.Debug("failed to get service user", "err", err) | ||
| return Principal{}, err | ||
| } | ||
| return Principal{ | ||
| ID: currentUser.ID, | ||
| Type: schema.ServiceUserPrincipal, | ||
| ServiceUser: ¤tUser, | ||
| }, nil | ||
| } | ||
|
|
||
| currentUser, err := s.userService.GetByID(ctx, userID) | ||
| if err != nil { | ||
| s.log.Debug("failed to get user", "err", err) | ||
| return Principal{}, err | ||
| } | ||
| return Principal{ | ||
| ID: currentUser.ID, | ||
| Type: schema.UserPrincipal, | ||
| User: ¤tUser, | ||
| }, nil | ||
| } | ||
|
|
||
| // NOTE: in the original code, a valid JWT without GeneratedClaimKey fell through to | ||
| // GetByJWT within the same if-block. errSkip preserves that behavior. | ||
| return Principal{}, errSkip | ||
| } | ||
|
|
||
| // authenticateWithJWTGrant validates a service user JWT grant token. | ||
| // Copied from original GetPrincipal jwt grant block. | ||
| func authenticateWithJWTGrant(ctx context.Context, s *Service) (Principal, error) { | ||
| userToken, ok := GetTokenFromContext(ctx) | ||
| if !ok { | ||
| return Principal{}, errSkip | ||
| } | ||
|
|
||
| serviceUser, err := s.serviceUserService.GetByJWT(ctx, userToken) | ||
| if err == nil { | ||
| return Principal{ | ||
| ID: serviceUser.ID, | ||
| Type: schema.ServiceUserPrincipal, | ||
| ServiceUser: &serviceUser, | ||
| }, nil | ||
| } | ||
| s.log.Debug("failed to parse as user token ", "err", err) | ||
| return Principal{}, errors.ErrUnauthenticated | ||
| } | ||
|
|
||
| // authenticateWithClientCredentials validates client_id:client_secret credentials. | ||
| // Copied from original GetPrincipal client credentials block. | ||
| func authenticateWithClientCredentials(ctx context.Context, s *Service) (Principal, error) { | ||
| userSecretRaw, ok := GetSecretFromContext(ctx) | ||
| if !ok { | ||
| return Principal{}, errSkip | ||
| } | ||
|
|
||
| // verify client secret | ||
| userSecret, err := base64.StdEncoding.DecodeString(userSecretRaw) | ||
| if err != nil { | ||
| s.log.Debug("failed to decode user secret", "err", err) | ||
| return Principal{}, errors.ErrUnauthenticated | ||
| } | ||
| userSecretParts := strings.Split(string(userSecret), ":") | ||
| if len(userSecretParts) != 2 { | ||
| s.log.Debug("failed to parse user secret") | ||
| return Principal{}, errors.ErrUnauthenticated | ||
| } | ||
| clientID, clientSecret := userSecretParts[0], userSecretParts[1] | ||
|
|
||
| // extract user from secret if it's a service user | ||
| serviceUser, err := s.serviceUserService.GetBySecret(ctx, clientID, clientSecret) | ||
| if err == nil { | ||
| return Principal{ | ||
| ID: serviceUser.ID, | ||
| Type: schema.ServiceUserPrincipal, | ||
| ServiceUser: &serviceUser, | ||
| }, nil | ||
| } | ||
| s.log.Debug("failed to authenticate with client credentials", "err", err) | ||
| return Principal{}, errors.ErrUnauthenticated | ||
| } | ||
|
|
||
| // authenticateWithPassthroughHeader extracts user from email header. | ||
| // Copied from original GetPrincipal passthrough block. | ||
| func authenticateWithPassthroughHeader(ctx context.Context, s *Service) (Principal, error) { | ||
| // check if header with user email is set | ||
| // TODO(kushsharma): this should ideally be deprecated | ||
| val, ok := GetEmailFromContext(ctx) | ||
| if !ok || len(val) == 0 { | ||
| return Principal{}, errSkip | ||
| } | ||
|
|
||
| currentUser, err := s.getOrCreateUser(ctx, strings.TrimSpace(val), strings.Split(val, "@")[0]) | ||
| if err != nil { | ||
| s.log.Debug("failed to get user", "err", err) | ||
| return Principal{}, err | ||
| } | ||
| return Principal{ | ||
| ID: currentUser.ID, | ||
| Type: schema.UserPrincipal, | ||
| User: ¤tUser, | ||
| }, nil | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.