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) }