package barcode
import (
"context"
"html"
"time"
"github.com/ory/fosite"
"github.com/ory/fosite/handler/oauth2"
"github.com/ory/fosite/handler/openid"
"github.com/ory/fosite/token/hmac"
"github.com/ory/x/errorsx"
"edge-infra.dev/pkg/edge/iam/config"
"edge-infra.dev/pkg/edge/iam/device"
"edge-infra.dev/pkg/edge/iam/profile"
"edge-infra.dev/pkg/edge/iam/prometheus"
"edge-infra.dev/pkg/edge/iam/session"
"edge-infra.dev/pkg/edge/iam/util"
iamErrors "edge-infra.dev/pkg/edge/iam/errors"
)
// GrantHandler exchanges a barcode for an accesstoken
type GrantHandler struct {
// barcode
BarcodeStrategy *OpaqueStrategy
SignedStrategy *SignedStrategy
LoginHintStrategy *hmac.HMACStrategy
BarcodeStorage Storage
// access token
AccessTokenStrategy oauth2.AccessTokenStrategy
ScopeStrategy fosite.ScopeStrategy
AccessTokenStorage oauth2.AccessTokenStorage
ProfileStorage profile.Storage
DeviceStorage device.Storage
LoginSessionStorage session.LoginSessionStorage
ProfileTTL time.Duration
BarcodeLength uint8
// refresh token
RefreshTokenStrategy oauth2.RefreshTokenStrategy
RefreshTokenStorage oauth2.RefreshTokenStorage
RefreshTokenScopes []string
RevocationStorage oauth2.TokenRevocationStorage
// id token
IDTokenHandleHelper *openid.IDTokenHandleHelper
// Custom metrics
Metrics *prometheus.Metrics
}
// can handle 'barcode' grant type
func (h *GrantHandler) CanHandleTokenEndpointRequest(requester fosite.AccessRequester) bool {
return requester.GetGrantTypes().ExactOne("barcode")
}
// require client authentication
func (h *GrantHandler) CanSkipClientAuth(_ fosite.AccessRequester) bool {
return false
}
// try to exchange barcode for subject
func (h *GrantHandler) HandleTokenEndpointRequest(ctx context.Context, requester fosite.AccessRequester) error {
// TODO:
// replace 7 with BarcodeInvalid after we migrated/created constants
// make sure we only handle 'barcode' flow
if !requester.GetGrantTypes().ExactOne("barcode") {
return errorsx.WithStack(fosite.ErrUnknownRequest)
}
h.Metrics.IncHTTPRequestsTotal(signInBarcode)
// get the client making the request
client := requester.GetClient()
s := session.FromRequester(requester)
// make sure its a private client
if client.IsPublic() {
hint := "The OAuth 2.0 Client is marked as public and is thus not allowed to use authorization grant 'barcode'."
return errorsx.WithStack(fosite.ErrInvalidGrant.WithHint(hint))
}
// make sure the client is allowed
if !client.GetGrantTypes().Has("barcode") {
hint := "The OAuth 2.0 Client is not allowed to use authorization grant 'barcode'."
return errorsx.WithStack(fosite.ErrUnauthorizedClient.WithHint(hint))
}
// we're only working with first class citizens at the moment, so give them what they want, but only if originally part of their client manifest.
for _, scope := range requester.GetRequestedScopes() {
if !h.ScopeStrategy(client.GetScopes(), scope) {
return errorsx.WithStack(fosite.ErrInvalidScope.WithHintf("The OAuth 2.0 Client is not allowed to request scope '%s'.", scope))
}
requester.GrantScope(scope)
}
// try to grab the barcode from request
barcode := requester.GetRequestForm().Get("barcode")
if barcode == "" {
// I feel like this is the user error
h.Metrics.IncSignInRequestsTotal(signInBarcode, util.Failed)
hint := "The barcode parameter is missing"
return errorsx.WithStack(fosite.ErrUnauthorizedClient.WithHint(hint))
}
clientID := client.GetID()
var userProfile *profile.Profile
var err error
// Check for prefix before proceeding with further checks
if barcode[:len(config.BarcodePrefix())] != config.BarcodePrefix() {
h.Metrics.IncSignInRequestsTotal(signInBarcode, util.Failed)
return errorsx.WithStack(fosite.ErrRequestUnauthorized.
WithWrap(h.invalidBarcodeScan(s, clientID, iamErrors.ErrUnrecognisedBarcode)))
}
if h.is128ABarcode(barcode) {
userProfile, err = h.handle128AScan(ctx, s, barcode, clientID)
if err != nil {
return err
}
} else {
userProfile, err = h.handleEBCScan(ctx, s, barcode, clientID)
if err != nil {
return err
}
}
for _, audience := range requester.GetGrantedAudience() {
requester.GrantAudience(audience)
}
// if userProfile.Organization != config.OrganizationID() {
// return fmt.Errorf("invalid user")
// }
s.SetOrg(userProfile.Organization)
s.SetRls(userProfile.Roles)
// add your profile info
s.SetGivenName(userProfile.GivenName)
s.SetFamilyName(userProfile.FamilyName)
s.SetAge(userProfile.Age)
s.SetDeviceLogin(userProfile.DeviceLogin)
s.SetFullName(userProfile.FullName)
s.SetEmail(userProfile.Email)
if userProfile.Address != nil {
s.SetAddress(userProfile.Address.ToMap())
}
// and set expiration
s.AsFosite().SetExpiresAt(fosite.AccessToken, accessExpiresAt())
s.AsFosite().SetExpiresAt(fosite.RefreshToken, refreshExpiresAt())
return nil
}
func hasValidRefreshToken(token string) error {
// expected to check only device login, as this piece of code is only hit when device login is enabled.
if util.IsDeviceLoginAvailable() {
return device.ExchangeRefreshToken(token)
}
return device.IsRefreshTokenValid(token)
}
func (h *GrantHandler) handle128AScan(ctx context.Context, s session.Session, barcode, clientID string) (*profile.Profile, error) {
// let's check the length of the barcode
if len(barcode) != int(h.BarcodeLength) {
h.Metrics.IncSignInRequestsTotal(signInBarcode, util.Failed)
return nil, errorsx.WithStack(fosite.ErrRequestUnauthorized.
WithWrap(h.invalidBarcodeScan(s, clientID, iamErrors.ErrUnrecognisedBarcode)))
}
// try and get it from storage
key := h.BarcodeStrategy.GetKey(barcode)
barcodeData, err := h.BarcodeStorage.GetBarcode(ctx, key)
if err != nil {
return nil, errorsx.WithStack(fosite.ErrRequestUnauthorized.
WithWrap(h.invalidBarcodeScan(s, clientID, iamErrors.ErrUnrecognisedBarcode)))
}
// place the identity in the session
userProfile, err := h.ProfileStorage.GetIdentityProfile(ctx, barcodeData.Subject)
if err != nil {
return nil, errorsx.WithStack(fosite.ErrServerError.
WithWrap(err).
WithDebug("storage error"))
}
if userProfile == nil {
return nil, errorsx.WithStack(fosite.ErrRequestUnauthorized.
WithWrap(h.invalidBarcodeScan(s, clientID, iamErrors.ErrUnrecognisedBarcode)))
}
s.SetSubject(userProfile.Subject)
// may be, we need to abstract and ensure refresh token ends up in userProfile
// the storage takes care of fetching from appropriate documents depending on type of login method
// and puts in user profile for refreshing here.
// the refresh method should also be available on cloud service used
// which is injected here for refreshing.
// or ensure, we understand this in our v3 :).
if config.DeviceLoginEnabled() {
if err := h.verifyDeviceAuth(ctx, s, userProfile, clientID); err != nil {
// treat a barcode auth, without valid cloud refresh token as an expired barcode.
return userProfile, err
}
} else {
if err := h.verifyProfile(s, userProfile, clientID); err != nil {
return userProfile, errorsx.WithStack(fosite.ErrRequestUnauthorized.
WithWrap(err).WithDebug("error verifying profile"))
}
}
currentBarcodeKey, err := h.BarcodeStorage.GetBarcodeUser(ctx, barcodeData.Subject)
if err != nil {
// remember that barcode = key + secret
// some error occurred while fetching the barcode-user value i.e, barcode key
return nil, errorsx.WithStack(fosite.ErrServerError.WithHint(err.Error()))
}
// now, let's check if the current scanned barcode key and last printed barcode key equals or not?
if currentBarcodeKey != key {
h.Metrics.IncSignInRequestsTotal(signInBarcode, util.Failed)
return nil, errorsx.WithStack(fosite.ErrRequestUnauthorized.
WithWrap(h.invalidBarcodeScan(s, clientID, iamErrors.ErrExpiredBarcode)))
}
// verify it - here we now the subject,
if err := h.BarcodeStrategy.Verify(barcode, barcodeData); err != nil {
h.Metrics.IncSignInRequestsTotal(signInBarcode, util.Failed)
if err == iamErrors.ErrExpiredBarcode {
return nil, errorsx.WithStack(fosite.ErrRequestUnauthorized.
WithWrap(h.invalidBarcodeScan(s, clientID, iamErrors.ErrExpiredBarcode)))
}
return nil, errorsx.WithStack(fosite.ErrRequestUnauthorized.
WithWrap(h.invalidBarcodeScan(s, clientID, iamErrors.ErrUnrecognisedBarcode)))
}
return userProfile, nil
}
func (h *GrantHandler) verifyDeviceAuth(ctx context.Context, s session.Session, userProfile *profile.Profile, clientID string) error {
deviceLogin := userProfile.DeviceLogin
acc, err := h.DeviceStorage.GetDeviceAccount(ctx, deviceLogin)
if err != nil {
return errorsx.WithStack(fosite.ErrServerError.
WithWrap(err).
WithDebug("error fetching device account from storage"))
}
if acc == nil {
return errorsx.WithStack(fosite.ErrRequestUnauthorized.
WithWrap(h.invalidBarcodeScan(s, clientID, iamErrors.ErrUnrecognisedBarcode)).
WithDebug("no such device account"))
}
// todo: inject into struct, may be via cloud svc struct, so we can use the same signature for BSL/device/okta etc.
err = hasValidRefreshToken(acc.RefreshToken)
if err != nil {
return errorsx.WithStack(fosite.ErrRequestUnauthorized.
WithWrap(h.invalidBarcodeScan(s, clientID, iamErrors.ErrExpiredBarcode)))
}
return nil
}
func (h *GrantHandler) verifyProfile(s session.Session, userProfile *profile.Profile, clientID string) error {
verify, err := userProfile.RequireVerification(h.BarcodeStorage.IsOffline())
if err != nil {
return errorsx.WithStack(fosite.ErrServerError.
WithWrap(err).
WithDebug("profile error"))
}
if verify {
challenge, signature, err := h.LoginHintStrategy.Generate()
if err != nil {
return errorsx.WithStack(fosite.ErrServerError.WithHint(err.Error()))
}
s.SetChallenge(challenge)
err = h.LoginSessionStorage.SetLoginSession(signature, &session.LoginSession{
Subject: userProfile.Subject,
Reason: 1, //ProfileDataExpired is 1, hardcoding until reason enum is moved from identity to constants
Active: true,
ClientID: clientID,
LoginOptions: session.LoginOptions{},
})
if err != nil {
return errorsx.WithStack(fosite.ErrServerError.WithHint(err.Error()))
}
return iamErrors.ErrLoginRequired
}
return nil
}
func (h *GrantHandler) handleEBCScan(ctx context.Context, s session.Session, barcode, clientID string) (*profile.Profile, error) {
payload, err := h.SignedStrategy.Verify(barcode)
// do we have a valid emergency barcode?
if err == iamErrors.ErrUnrecognisedBarcode {
return nil, errorsx.WithStack(fosite.ErrRequestUnauthorized.
WithWrap(h.invalidBarcodeScan(s, clientID, iamErrors.ErrUnrecognisedBarcode)))
} else if err == iamErrors.ErrExpiredEBC {
return nil, errorsx.WithStack(fosite.ErrRequestUnauthorized.
WithWrap(h.invalidBarcodeScan(s, clientID, iamErrors.ErrExpiredBarcode)))
} else if err != nil {
return nil, errorsx.WithStack(fosite.ErrRequestUnauthorized.
WithWrap(h.invalidBarcodeScan(s, clientID, iamErrors.ErrUnrecognisedBarcode)))
}
// if we can reach BOTH:
// 1) couchDB on the control plane
// 2) Device Login/BSL/Okta (based on config)
// Then, we decline the EBC as it should be used only for offline scenario...
if !h.BarcodeStorage.IsOffline() && util.IsCloudLoginAvailable() {
return nil, errorsx.WithStack(fosite.ErrRequestUnauthorized.
WithWrap(h.invalidBarcodeScan(s, clientID, iamErrors.ErrInvalidEBCUsage)))
}
subject, err := h.ProfileStorage.GetSubjectFromAlias(ctx, payload.Subject)
if err != nil {
return nil, errorsx.WithStack(fosite.ErrServerError.
WithWrap(err).
WithDebug("storage error, missing subject for alias."))
}
userProfile, err := h.ProfileStorage.GetIdentityProfile(ctx, subject)
if err != nil {
return nil, errorsx.WithStack(fosite.ErrServerError.
WithWrap(err).
WithDebug("storage error"))
}
if userProfile == nil {
return nil, errorsx.WithStack(fosite.ErrRequestUnauthorized.
WithWrap(h.invalidBarcodeScan(s, clientID, iamErrors.ErrUnrecognisedBarcode)))
}
if time.Unix(userProfile.LastUpdated, 0).Sub(time.Unix(payload.IssuedAt, 0)) > 0 {
return nil, errorsx.WithStack(fosite.ErrRequestUnauthorized.
WithWrap(h.invalidBarcodeScan(s, clientID, iamErrors.ErrExpiredBarcode)))
}
s.SetSubject(userProfile.Subject)
return userProfile, nil
}
//nolint:unparam
func (h *GrantHandler) invalidBarcodeScan(s session.Session, clientID string, barcodeErr error) error {
challenge, signature, err := h.LoginHintStrategy.Generate()
if err != nil {
return errorsx.WithStack(fosite.ErrServerError.WithHint(err.Error()))
}
s.SetChallenge(challenge)
loginSession := &session.LoginSession{
Subject: "", // don't expose the subject
Reason: 7, // barcode invalid
Active: true,
ClientID: clientID,
LoginOptions: session.LoginOptions{
ErrorMessage: html.EscapeString(barcodeErr.Error()),
},
}
err = h.LoginSessionStorage.SetLoginSession(signature, loginSession)
if err != nil {
return errorsx.WithStack(fosite.ErrServerError.WithHint(err.Error()))
}
return barcodeErr
}
// issue access and possibly refresh and id tokens
func (h *GrantHandler) PopulateTokenEndpointResponse(ctx context.Context, requester fosite.AccessRequester, responder fosite.AccessResponder) (err error) {
// just making sure, I guess
if !h.CanHandleTokenEndpointRequest(requester) {
return errorsx.WithStack(fosite.ErrUnknownRequest)
}
// generate, store and populate the access token
if err = h.PopulateAccessToken(ctx, requester, responder); err != nil {
return err
}
// issue refresh token if offline scope is requested and client is allowed to
if h.CanIssueRefreshToken(requester) {
if err = h.PopulateRefreshToken(ctx, requester, responder); err != nil {
return err
}
}
// issue id token if openid scope is requested and client is allowed to
if h.CanIssueIDToken(requester) {
if err = h.PopulateIDToken(ctx, requester, responder); err != nil {
return err
}
}
h.Metrics.IncSignInRequestsTotal(signInBarcode, util.Succeeded)
return nil
}
func (h *GrantHandler) PopulateIDToken(ctx context.Context, requester fosite.AccessRequester, responder fosite.AccessResponder) error {
// get our session from the request
s := session.FromRequester(requester)
// we require a subject in the session in order to issue the ID token
claims := s.AsOpenID().Claims
if claims.Subject == "" {
return errorsx.WithStack(fosite.ErrServerError.WithDebug("Failed to generate id token because subject is an empty string."))
}
// we're linking the ID token to the issued accesstoken - we verify it at one point
claims.AccessTokenHash = h.IDTokenHandleHelper.GetAccessTokenHash(ctx, requester, responder)
// generate the ID token and populate the id_token field on the response
return h.IDTokenHandleHelper.IssueExplicitIDToken(ctx, requester, responder)
}
// PopulateAccessToken generates, stores and populates the response with the access token
func (h *GrantHandler) PopulateAccessToken(ctx context.Context, requester fosite.AccessRequester, responder fosite.AccessResponder) error {
// generate
accessToken, accessSignature, err := h.AccessTokenStrategy.GenerateAccessToken(ctx, requester)
if err != nil {
return errorsx.WithStack(fosite.ErrServerError.WithWrap(err).WithDebug(err.Error()))
}
// store
if err = h.AccessTokenStorage.CreateAccessTokenSession(ctx, accessSignature, requester.Sanitize([]string{})); err != nil {
return errorsx.WithStack(fosite.ErrServerError.WithWrap(err).WithDebug(err.Error()))
}
// respond
s := session.FromRequester(requester)
responder.SetAccessToken(accessToken)
responder.SetTokenType("bearer")
expiresAt := s.AsFosite().GetExpiresAt(fosite.AccessToken)
responder.SetExpiresIn(time.Until(expiresAt))
return nil
}
func (h *GrantHandler) CanIssueRefreshToken(requester fosite.Requester) bool {
// Require one of the refresh token scopes, if set.
if len(h.RefreshTokenScopes) > 0 && !requester.GetGrantedScopes().HasOneOf(h.RefreshTokenScopes...) {
return false
}
// Do not issue a refresh token to clients that cannot use the refresh token grant type.
if !requester.GetClient().GetGrantTypes().Has("refresh_token") {
return false
}
return true
}
// PopulateRefreshToken generates, stores and populates the response with the refresh token
func (h *GrantHandler) PopulateRefreshToken(ctx context.Context, requester fosite.AccessRequester, responder fosite.AccessResponder) error {
// generate
refreshToken, refreshSignature, err := h.RefreshTokenStrategy.GenerateRefreshToken(ctx, requester)
if err != nil {
return errorsx.WithStack(fosite.ErrServerError.WithWrap(err).WithDebug(err.Error()))
}
// store
if err = h.RefreshTokenStorage.CreateRefreshTokenSession(ctx, refreshSignature, requester.Sanitize([]string{})); err != nil {
return errorsx.WithStack(fosite.ErrServerError.WithWrap(err).WithDebug(err.Error()))
}
// populate
responder.SetExtra("refresh_token", refreshToken)
return nil
}
func (h *GrantHandler) CanIssueIDToken(requester fosite.Requester) bool {
// Require one of the refresh token scopes, if set.
if len(h.RefreshTokenScopes) > 0 && !requester.GetGrantedScopes().HasOneOf("openid") {
return false
}
// Do not issue a refresh token to clients that cannot use the refresh token grant type.
if !requester.GetClient().GetGrantTypes().Has("authorization_code") {
return false
}
return true
}
func (h *GrantHandler) is128ABarcode(barcode string) bool {
// placeholder for adding more validations
return len(barcode) <= int(h.BarcodeLength)
}