From 84eea0d012d87c907f8a02d98fa3db108c4832bc Mon Sep 17 00:00:00 2001 From: Dmitrii Kozlov Date: Fri, 16 Aug 2024 23:40:56 +0300 Subject: [PATCH 1/4] audit --- jwt/service/jwt_token_service.go | 6 ++++- model/slice.go | 1 + model/token.go | 13 +++++++++++ storage/grpc/shared/server.go | 10 ++++---- web/api/2fa.go | 13 ++++------- web/api/audit.go | 8 +++++++ web/api/federated_login.go | 4 +++- web/api/federated_oidc_login.go | 6 +++-- web/api/federated_oidc_login_test.go | 19 +++++++++++---- web/api/impersonate_as.go | 10 ++++---- web/api/login.go | 35 ++++++++++++++-------------- web/api/logout.go | 20 ++++++++-------- web/api/phone_login.go | 11 +++------ web/api/registration.go | 10 +++----- 14 files changed, 98 insertions(+), 68 deletions(-) create mode 100644 web/api/audit.go diff --git a/jwt/service/jwt_token_service.go b/jwt/service/jwt_token_service.go index 3ce3bc59..c073b438 100644 --- a/jwt/service/jwt_token_service.go +++ b/jwt/service/jwt_token_service.go @@ -343,9 +343,13 @@ func (ts *JWTokenService) RefreshAccessToken(refreshToken model.Token, tokenPayl return nil, ErrInvalidUser } + requestedScopes := strings.Split(claims.Scopes, " ") + + scopes := model.AllowedScopes(requestedScopes, user.Scopes, app.Offline) + token, err := ts.NewAccessToken( user, - strings.Split(claims.Scopes, " "), + scopes, app, false, tokenPayload) diff --git a/model/slice.go b/model/slice.go index 017edc7b..bc799adc 100644 --- a/model/slice.go +++ b/model/slice.go @@ -2,6 +2,7 @@ package model import "strings" +// SliceIntersect returns only items in as that are found in bs. // simple intersection of two slices, with complexity: O(n^2) // there is better algorithms around, this one is simple and scopes are usually 1-3 items in it func SliceIntersect(a, b []string) []string { diff --git a/model/token.go b/model/token.go index 6f85ca7b..b36eb981 100644 --- a/model/token.go +++ b/model/token.go @@ -181,3 +181,16 @@ type Claims struct { // Full example of how to use JWT tokens: // https://github.com/form3tech-oss/jwt-go/blob/master/cmd/jwt/app.go + +func AllowedScopes(requestedScopes, userScopes []string, isOffline bool) []string { + scopes := []string{} + // if we requested any scope, let's provide all the scopes user has and requested + if len(requestedScopes) > 0 { + scopes = SliceIntersect(requestedScopes, userScopes) + } + if SliceContains(requestedScopes, "offline") && isOffline { + scopes = append(scopes, "offline") + } + + return scopes +} diff --git a/storage/grpc/shared/server.go b/storage/grpc/shared/server.go index 5aa2986e..b249ba35 100644 --- a/storage/grpc/shared/server.go +++ b/storage/grpc/shared/server.go @@ -18,7 +18,7 @@ type GRPCServer struct { func (m *GRPCServer) UserByPhone(ctx context.Context, in *proto.UserByPhoneRequest) (*proto.User, error) { user, err := m.Impl.UserByPhone(in.Phone) if err == model.ErrUserNotFound { - return toProto(user), status.Errorf(codes.NotFound, err.Error()) + return toProto(user), status.Error(codes.NotFound, err.Error()) } return toProto(user), err } @@ -31,7 +31,7 @@ func (m *GRPCServer) AddUserWithPassword(ctx context.Context, in *proto.AddUserW func (m *GRPCServer) UserByID(ctx context.Context, in *proto.UserByIDRequest) (*proto.User, error) { user, err := m.Impl.UserByID(in.Id) if err == model.ErrUserNotFound { - return toProto(user), status.Errorf(codes.NotFound, err.Error()) + return toProto(user), status.Error(codes.NotFound, err.Error()) } return toProto(user), err } @@ -39,7 +39,7 @@ func (m *GRPCServer) UserByID(ctx context.Context, in *proto.UserByIDRequest) (* func (m *GRPCServer) UserByEmail(ctx context.Context, in *proto.UserByEmailRequest) (*proto.User, error) { user, err := m.Impl.UserByEmail(in.Email) if err == model.ErrUserNotFound { - return toProto(user), status.Errorf(codes.NotFound, err.Error()) + return toProto(user), status.Error(codes.NotFound, err.Error()) } return toProto(user), err } @@ -47,7 +47,7 @@ func (m *GRPCServer) UserByEmail(ctx context.Context, in *proto.UserByEmailReque func (m *GRPCServer) UserByUsername(ctx context.Context, in *proto.UserByUsernameRequest) (*proto.User, error) { user, err := m.Impl.UserByUsername(in.Username) if err == model.ErrUserNotFound { - return toProto(user), status.Errorf(codes.NotFound, err.Error()) + return toProto(user), status.Error(codes.NotFound, err.Error()) } return toProto(user), err } @@ -55,7 +55,7 @@ func (m *GRPCServer) UserByUsername(ctx context.Context, in *proto.UserByUsernam func (m *GRPCServer) UserByFederatedID(ctx context.Context, in *proto.UserByFederatedIDRequest) (*proto.User, error) { user, err := m.Impl.UserByFederatedID(in.Provider, in.Id) if err == model.ErrUserNotFound { - return toProto(user), status.Errorf(codes.NotFound, err.Error()) + return toProto(user), status.Error(codes.NotFound, err.Error()) } return toProto(user), err } diff --git a/web/api/2fa.go b/web/api/2fa.go index d55a2a42..7f173842 100644 --- a/web/api/2fa.go +++ b/web/api/2fa.go @@ -195,7 +195,7 @@ func (ar *Router) ResendTFA() http.HandlerFunc { scopes := strings.Split(token.Scopes(), " ") - authResult, err := ar.loginFlow(app, user, scopes, nil) + authResult, _, err := ar.loginFlow(app, user, scopes, nil) if err != nil { ar.Error(w, locale, http.StatusInternalServerError, l.APIInternalServerErrorWithError, err) return @@ -265,14 +265,7 @@ func (ar *Router) FinalizeTFA() http.HandlerFunc { return } - scopes := []string{} - // if we requested any scope, let's provide all the scopes user has and requested - if len(d.Scopes) > 0 { - scopes = model.SliceIntersect(d.Scopes, user.Scopes) - } - if model.SliceContains(d.Scopes, "offline") && app.Offline { - scopes = append(scopes, "offline") - } + scopes := model.AllowedScopes(d.Scopes, app.Scopes, app.Offline) tokenPayload, err := ar.getTokenPayloadForApp(app, user.ID) if err != nil { @@ -312,6 +305,8 @@ func (ar *Router) FinalizeTFA() http.HandlerFunc { } } + journal(user.ID, app.ID, "2fa_login", scopes) + ar.server.Storages().User.UpdateLoginMetadata(user.ID) ar.ServeJSON(w, locale, http.StatusOK, result) } diff --git a/web/api/audit.go b/web/api/audit.go new file mode 100644 index 00000000..2bdbf5c0 --- /dev/null +++ b/web/api/audit.go @@ -0,0 +1,8 @@ +package api + +import "log" + +func journal(userID, appID, action string, scopes []string) { + log.Printf("audit record | %s | userID=%s appID=%s scopes=%v\n", + action, userID, appID, scopes) +} diff --git a/web/api/federated_login.go b/web/api/federated_login.go index 3da12c54..78fc7def 100644 --- a/web/api/federated_login.go +++ b/web/api/federated_login.go @@ -203,7 +203,7 @@ func (ar *Router) FederatedLoginComplete() http.HandlerFunc { return } - authResult, err := ar.loginFlow(app, user, fsess.Scopes, nil) + authResult, resultScopes, err := ar.loginFlow(app, user, fsess.Scopes, nil) if err != nil { ar.Error(w, locale, http.StatusInternalServerError, l.ErrorFederatedLoginError, err) return @@ -212,6 +212,8 @@ func (ar *Router) FederatedLoginComplete() http.HandlerFunc { authResult.CallbackUrl = fsess.CallbackUrl authResult.Scopes = fsess.Scopes + journal(user.ID, app.ID, "federated_login", resultScopes) + ar.ServeJSON(w, locale, http.StatusOK, authResult) } } diff --git a/web/api/federated_oidc_login.go b/web/api/federated_oidc_login.go index 55755bd7..e07cc96a 100644 --- a/web/api/federated_oidc_login.go +++ b/web/api/federated_oidc_login.go @@ -237,7 +237,7 @@ func (ar *Router) OIDCLoginComplete(useSession bool) http.HandlerFunc { // map OIDC scopes to Identifo scopes requestedScopes = mapScopes(app.OIDCSettings.ScopeMapping, requestedScopes) - authResult, err := ar.loginFlow(app, user, requestedScopes, nil) + authResult, resultScopes, err := ar.loginFlow(app, user, requestedScopes, nil) if err != nil { ar.Error(w, locale, http.StatusInternalServerError, l.ErrorFederatedLoginError, err) return @@ -247,9 +247,11 @@ func (ar *Router) OIDCLoginComplete(useSession bool) http.HandlerFunc { authResult.CallbackUrl = fsess.CallbackUrl } - authResult.Scopes = requestedScopes + authResult.Scopes = resultScopes authResult.ProviderData = *providerData + journal(user.ID, app.ID, "oidc_login", resultScopes) + ar.ServeJSON(w, locale, http.StatusOK, authResult) } } diff --git a/web/api/federated_oidc_login_test.go b/web/api/federated_oidc_login_test.go index 7760eea9..6fc87102 100644 --- a/web/api/federated_oidc_login_test.go +++ b/web/api/federated_oidc_login_test.go @@ -19,9 +19,10 @@ import ( ) var testApp = model.AppData{ - ID: "test_app", - Active: true, - Type: model.Web, + ID: "test_app", + Active: true, + Offline: true, + Type: model.Web, OIDCSettings: model.OIDCSettings{ ProviderName: "test", ClientID: "test", @@ -62,6 +63,8 @@ func init() { panic(err) } + testServer.Storages().App.CreateApp(testApp) + rs := api.RouterSettings{ LoginWith: model.LoginWith{ FederatedOIDC: true, @@ -228,12 +231,20 @@ func Test_Router_OIDCLogin_Complete_ByEmail(t *testing.T) { } func claimsFromResponse(t *testing.T, response []byte) jwt.MapClaims { + return claimsFromJSONResponse(t, "access_token", response) +} + +func refreshClaimsFromResponse(t *testing.T, response []byte) jwt.MapClaims { + return claimsFromJSONResponse(t, "refresh_token", response) +} + +func claimsFromJSONResponse(t *testing.T, token_field string, response []byte) jwt.MapClaims { var token map[string]any err := json.Unmarshal(response, &token) require.NoError(t, err) - at := token["access_token"].(string) + at := token[token_field].(string) require.NotEmpty(t, at) c := jwt.MapClaims{} diff --git a/web/api/impersonate_as.go b/web/api/impersonate_as.go index c7316e74..2de3ebeb 100644 --- a/web/api/impersonate_as.go +++ b/web/api/impersonate_as.go @@ -27,20 +27,20 @@ func (ar *Router) ImpersonateAs() http.HandlerFunc { return } - userID := tokenFromContext(r.Context()).UserID() + userID := tokenFromContext(ctx).UserID() adminUser, err := ar.server.Storages().User.UserByID(userID) if err != nil { ar.Error(w, locale, http.StatusUnauthorized, l.ErrorStorageFindUserIDError, userID, err) return } - log.Println("admin for impersonation", adminUser.ID, adminUser.Scopes) - d := impersonateData{} if ar.MustParseJSON(w, r, &d) != nil { return } + log.Println("admin for impersonation", adminUser.ID, adminUser.Scopes, "as", d.UserID) + user, err := ar.server.Storages().User.UserByID(d.UserID) if err != nil { ar.Error(w, locale, http.StatusUnauthorized, l.ErrorStorageFindUserIDError, d.UserID, err) @@ -63,7 +63,7 @@ func (ar *Router) ImpersonateAs() http.HandlerFunc { "impersonated_by": adminUser.ID, } - authResult, err := ar.loginFlow(app, user, nil, ap) + authResult, resultScopes, err := ar.loginFlow(app, user, nil, ap) if err != nil { ar.Error(w, locale, http.StatusInternalServerError, l.ErrorAPILoginError, err) return @@ -72,6 +72,8 @@ func (ar *Router) ImpersonateAs() http.HandlerFunc { // do not allow refresh for impersonated user authResult.RefreshToken = "" + journal(userID, app.ID, "impersonated", resultScopes) + ar.ServeJSON(w, locale, http.StatusOK, authResult) } } diff --git a/web/api/login.go b/web/api/login.go index 760f909d..7bf0d54d 100644 --- a/web/api/login.go +++ b/web/api/login.go @@ -182,12 +182,14 @@ func (ar *Router) LoginWithPassword() http.HandlerFunc { return } - authResult, err := ar.loginFlow(app, user, ld.Scopes, nil) + authResult, resultScopes, err := ar.loginFlow(app, user, ld.Scopes, nil) if err != nil { ar.Error(w, locale, http.StatusInternalServerError, l.ErrorAPILoginError, err) return } + journal(authResult.User.ID, app.ID, "pass_login", resultScopes) + ar.ServeJSON(w, locale, http.StatusOK, authResult) } } @@ -304,7 +306,13 @@ func (ar *Router) getTokenPayloadService(app model.AppData) (model.TokenPayloadP // loginUser creates and returns access token for a user. // createRefreshToken boolean param tells if we should issue refresh token as well. -func (ar *Router) loginUser(user model.User, scopes []string, app model.AppData, createRefreshToken, require2FA bool, tokenPayload map[string]interface{}) (string, string, error) { +func (ar *Router) loginUser( + user model.User, + scopes []string, + app model.AppData, + createRefreshToken, require2FA bool, + tokenPayload map[string]interface{}, +) (string, string, error) { token, err := ar.server.Services().Token.NewAccessToken(user, scopes, app, require2FA, tokenPayload) if err != nil { return "", "", err @@ -335,33 +343,26 @@ func (ar *Router) loginFlow( user model.User, requestedScopes []string, additionalPayload map[string]any, -) (AuthResponse, error) { +) (AuthResponse, []string, error) { // check if the user has the scope, that allows to login to the app // user has to have at least one scope app expecting if len(app.Scopes) > 0 && len(model.SliceIntersect(app.Scopes, user.Scopes)) == 0 { - return AuthResponse{}, errors.New("user does not have required scope for the app") + return AuthResponse{}, nil, errors.New("user does not have required scope for the app") } // Do login flow. - scopes := []string{} - // if we requested any scope, let's provide all the scopes user has and requested - if len(requestedScopes) > 0 { - scopes = model.SliceIntersect(requestedScopes, user.Scopes) - } - if model.SliceContains(requestedScopes, "offline") && app.Offline { - scopes = append(scopes, "offline") - } + scopes := model.AllowedScopes(requestedScopes, user.Scopes, app.Offline) // Check if we should require user to authenticate with 2FA. require2FA, enabled2FA, err := ar.check2FA(app.TFAStatus, ar.tfaType, user) if !require2FA && enabled2FA && err != nil { - return AuthResponse{}, err + return AuthResponse{}, nil, err } offline := contains(scopes, model.OfflineScope) tokenPayload, err := ar.getTokenPayloadForApp(app, user.ID) if err != nil { - return AuthResponse{}, err + return AuthResponse{}, nil, err } if tokenPayload == nil { @@ -374,7 +375,7 @@ func (ar *Router) loginFlow( accessToken, refreshToken, err := ar.loginUser(user, scopes, app, offline, require2FA, tokenPayload) if err != nil { - return AuthResponse{}, err + return AuthResponse{}, nil, err } result := AuthResponse{ @@ -386,7 +387,7 @@ func (ar *Router) loginFlow( if require2FA && enabled2FA { if err := ar.sendOTPCode(app, user); err != nil { - return AuthResponse{}, err + return AuthResponse{}, nil, err } } else { ar.server.Storages().User.UpdateLoginMetadata(user.ID) @@ -394,7 +395,7 @@ func (ar *Router) loginFlow( user = user.Sanitized() result.User = user - return result, nil + return result, scopes, nil } type impersonateData struct { diff --git a/web/api/logout.go b/web/api/logout.go index bb1f6715..f496601f 100644 --- a/web/api/logout.go +++ b/web/api/logout.go @@ -18,9 +18,12 @@ func (ar *Router) Logout() http.HandlerFunc { result := map[string]string{"result": "ok"} return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() locale := r.Header.Get("Accept-Language") - accessTokenBytes, ok := r.Context().Value(model.TokenRawContextKey).([]byte) + accessToken := tokenFromContext(ctx) + + accessTokenBytes, ok := ctx.Value(model.TokenRawContextKey).([]byte) if !ok { ar.logger.Println("Cannot fetch access token bytes from context") ar.ServeJSON(w, locale, http.StatusNoContent, nil) @@ -56,23 +59,20 @@ func (ar *Router) Logout() http.HandlerFunc { } } + journal(accessToken.Subject(), accessToken.Audience(), "logout", nil) + ar.ServeJSON(w, locale, http.StatusOK, result) } } func (ar *Router) getTokenSubject(tokenString string) (string, error) { - claims := jwt.MapClaims{} - - if _, err := jwt.ParseWithClaims(tokenString, claims, nil); err == nil { - return "", fmt.Errorf("Cannot parse token: %s", err) - } + claims := jwt.RegisteredClaims{} - sub, ok := claims["sub"].(string) - if !ok { - return "", fmt.Errorf("Cannot obtain token subject") + if _, err := jwt.ParseWithClaims(tokenString, &claims, nil); err == nil { + return "", fmt.Errorf("cannot parse token: %s", err) } - return sub, nil + return claims.Subject, nil } func (ar *Router) revokeRefreshToken(refreshTokenString, accessTokenString string) error { diff --git a/web/api/phone_login.go b/web/api/phone_login.go index 0d275a41..542735c9 100644 --- a/web/api/phone_login.go +++ b/web/api/phone_login.go @@ -142,14 +142,7 @@ func (ar *Router) PhoneLogin() http.HandlerFunc { } // Do login flow. - scopes := []string{} - // if we requested any scope, let's provide all the scopes user has and requested - if len(authData.Scopes) > 0 { - scopes = model.SliceIntersect(authData.Scopes, user.Scopes) - } - if model.SliceContains(authData.Scopes, "offline") && app.Offline { - scopes = append(scopes, "offline") - } + scopes := model.AllowedScopes(authData.Scopes, user.Scopes, app.Offline) tokenPayload, err := ar.getTokenPayloadForApp(app, user.ID) if err != nil { @@ -171,6 +164,8 @@ func (ar *Router) PhoneLogin() http.HandlerFunc { User: user, } + journal(user.ID, app.ID, "phone_login", scopes) + ar.server.Storages().User.UpdateLoginMetadata(user.ID) ar.ServeJSON(w, locale, http.StatusOK, result) } diff --git a/web/api/registration.go b/web/api/registration.go index eb9962af..7182d77a 100644 --- a/web/api/registration.go +++ b/web/api/registration.go @@ -62,12 +62,6 @@ func (rd *registrationData) validate() error { // RegisterWithPassword registers new user with password. func (ar *Router) RegisterWithPassword() http.HandlerFunc { - type registrationResponse struct { - AccessToken string `json:"access_token,omitempty"` - RefreshToken string `json:"refresh_token,omitempty"` - User model.User `json:"user,omitempty"` - } - return func(w http.ResponseWriter, r *http.Request) { locale := r.Header.Get("Accept-Language") @@ -179,12 +173,14 @@ func (ar *Router) RegisterWithPassword() http.HandlerFunc { // return // Do login flow. - authResult, err := ar.loginFlow(app, user, rd.Scopes, nil) + authResult, resultScopes, err := ar.loginFlow(app, user, rd.Scopes, nil) if err != nil { ar.Error(w, locale, http.StatusInternalServerError, l.ErrorAPILoginError, err) return } + journal(user.ID, app.ID, "registration", resultScopes) + ar.ServeJSON(w, locale, http.StatusOK, authResult) } } From 18d791023914e7382e994409a94511aac867f40d Mon Sep 17 00:00:00 2001 From: Dmitrii Kozlov Date: Fri, 16 Aug 2024 23:41:07 +0300 Subject: [PATCH 2/4] refresh token correct scopes --- web/api/refresh_token.go | 22 ++++++++--- web/api/refresh_token_test.go | 69 +++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 5 deletions(-) create mode 100644 web/api/refresh_token_test.go diff --git a/web/api/refresh_token.go b/web/api/refresh_token.go index ebde6492..45ef1d58 100644 --- a/web/api/refresh_token.go +++ b/web/api/refresh_token.go @@ -3,13 +3,14 @@ package api import ( "encoding/json" "net/http" + "strings" l "github.com/madappgang/identifo/v2/localization" "github.com/madappgang/identifo/v2/model" "github.com/madappgang/identifo/v2/web/middleware" ) -// RefreshTokens issues new access and, if requsted, refresh token for provided refresh token. +// RefreshTokens issues new access and, if requested, refresh token for provided refresh token. // After new tokens are issued, the old refresh token gets invalidated (via blacklisting). func (ar *Router) RefreshTokens() http.HandlerFunc { type requestData struct { @@ -22,6 +23,8 @@ func (ar *Router) RefreshTokens() http.HandlerFunc { } return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + locale := r.Header.Get("Accept-Language") rd := requestData{} @@ -30,14 +33,14 @@ func (ar *Router) RefreshTokens() http.HandlerFunc { rd = requestData{Scopes: []string{}} } - app := middleware.AppFromContext(r.Context()) + app := middleware.AppFromContext(ctx) if len(app.ID) == 0 { ar.Error(w, locale, http.StatusBadRequest, l.ErrorAPIAPPNoAPPInContext) return } // Get refresh token from context. - oldRefreshToken := tokenFromContext(r.Context()) + oldRefreshToken := tokenFromContext(ctx) if err := oldRefreshToken.Validate(); err != nil { ar.Error(w, locale, http.StatusUnauthorized, l.ErrorTokenInvalidError, err) @@ -84,12 +87,19 @@ func (ar *Router) RefreshTokens() http.HandlerFunc { RefreshToken: newRefreshTokenString, } + resultScopes := strings.Split(accessToken.Scopes(), " ") + journal(oldRefreshToken.Subject(), app.ID, "refresh_token", resultScopes) + ar.ServeJSON(w, locale, http.StatusOK, result) } } -func (ar *Router) issueNewRefreshToken(oldRefreshTokenString string, scopes []string, app model.AppData) (string, error) { - if !contains(scopes, model.OfflineScope) { // Don't issue new refresh token if not requested. +func (ar *Router) issueNewRefreshToken( + oldRefreshTokenString string, + requestedScopes []string, + app model.AppData, +) (string, error) { + if !contains(requestedScopes, model.OfflineScope) { // Don't issue new refresh token if not requested. return "", nil } @@ -103,6 +113,8 @@ func (ar *Router) issueNewRefreshToken(oldRefreshTokenString string, scopes []st return "", err } + scopes := model.AllowedScopes(requestedScopes, user.Scopes, app.Offline) + refreshToken, err := ar.server.Services().Token.NewRefreshToken(user, scopes, app) if err != nil { return "", err diff --git a/web/api/refresh_token_test.go b/web/api/refresh_token_test.go new file mode 100644 index 00000000..eb0025b2 --- /dev/null +++ b/web/api/refresh_token_test.go @@ -0,0 +1,69 @@ +package api_test + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/madappgang/identifo/v2/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRefreshTokens(t *testing.T) { + ctx := testContext(testApp) + + reqBody := `{"scopes":["offline", "chat", "super_admin"]}` + + user := model.User{ + ID: "test_user", + Scopes: []string{"chat"}, + Active: true, + Email: "rt_some@example.com", + Username: "rt_some", + Phone: "1234567890", + AccessRole: "user", + } + + user, err := testServer.Storages().User.AddUserWithPassword(user, "qwerty", "user", false) + require.NoError(t, err) + + tokenService := testServer.Services().Token + + refreshToken, err := tokenService.NewRefreshToken( + user, + []string{"offline", "chat", "super_admin"}, + testApp) + require.NoError(t, err) + + rts, err := tokenService.String(refreshToken) + require.NoError(t, err) + + refreshToken, err = tokenService.Parse(rts) + require.NoError(t, err) + + ctx = context.WithValue(ctx, model.TokenContextKey, refreshToken) + ctx = context.WithValue(ctx, model.TokenRawContextKey, []byte(rts)) + + req := httptest.NewRequest(http.MethodPost, "/auth/token", strings.NewReader(reqBody)) + req = req.WithContext(ctx) + + rw := httptest.NewRecorder() + + h := testRouter.RefreshTokens() + h(rw, req) + + require.Equal(t, http.StatusOK, rw.Code, rw.Body.String()) + + c := claimsFromResponse(t, rw.Body.Bytes()) + assert.Equal(t, user.ID, c["sub"]) + assert.Equal(t, "test_app", c["aud"]) + assert.Equal(t, "chat offline", c["scopes"]) + + c = refreshClaimsFromResponse(t, rw.Body.Bytes()) + assert.Equal(t, user.ID, c["sub"]) + assert.Equal(t, "test_app", c["aud"]) + assert.Equal(t, "chat offline", c["scopes"]) +} From d0f23c4befff239ad30ee231cb7fe9c7e4a3a06e Mon Sep 17 00:00:00 2001 From: Dmitrii Kozlov Date: Sat, 17 Aug 2024 00:05:56 +0300 Subject: [PATCH 3/4] switch to docker compose --- test/docker-compose.yml | 2 -- test/test.sh | 4 ++-- web/api/audit.go | 2 ++ 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/test/docker-compose.yml b/test/docker-compose.yml index d4574c90..9e74917a 100644 --- a/test/docker-compose.yml +++ b/test/docker-compose.yml @@ -1,6 +1,4 @@ # this is compose for test environment - -version: "3" services: minio: image: quay.io/minio/minio:latest diff --git a/test/test.sh b/test/test.sh index 382ddcad..1acdb34a 100755 --- a/test/test.sh +++ b/test/test.sh @@ -11,7 +11,7 @@ export IDENTIFO_STORAGE_MONGO_TEST_INTEGRATION=1 export IDENTIFO_STORAGE_MONGO_CONN="mongodb://admin:password@localhost:27017/billing-local?authSource=admin" export IDENTIFO_REDIS_HOST="127.0.0.1:6379" -docker-compose up -d +docker compose up -d sleep 1 echo "dependencies started" @@ -20,6 +20,6 @@ go test -race -timeout=60s -count=1 ../... test_exit=$? # docker-compose down -v -docker-compose rm -s -f -v +docker compose rm -s -f -v exit $test_exit diff --git a/web/api/audit.go b/web/api/audit.go index 2bdbf5c0..b9fe6aa3 100644 --- a/web/api/audit.go +++ b/web/api/audit.go @@ -3,6 +3,8 @@ package api import "log" func journal(userID, appID, action string, scopes []string) { + // TODO: Create an interface for the audit log + // Implement it for logging to stdout, a database, or a remote service log.Printf("audit record | %s | userID=%s appID=%s scopes=%v\n", action, userID, appID, scopes) } From 8ccd92a92e090672477429dc2b84598f7a15a0a0 Mon Sep 17 00:00:00 2001 From: Dmitrii Kozlov Date: Sat, 17 Aug 2024 14:17:26 +0300 Subject: [PATCH 4/4] change token claims behaviour --- jwt/service/jwt_token_service.go | 8 ++++---- model/token.go | 2 +- web/api/federated_oidc_login.go | 2 +- web/api/federated_oidc_server_test.go | 29 ++++++++++++++++++--------- 4 files changed, 25 insertions(+), 16 deletions(-) diff --git a/jwt/service/jwt_token_service.go b/jwt/service/jwt_token_service.go index c073b438..e36736c2 100644 --- a/jwt/service/jwt_token_service.go +++ b/jwt/service/jwt_token_service.go @@ -232,7 +232,7 @@ func (ts *JWTokenService) NewAccessToken(u model.User, scopes []string, app mode lifespan = TokenLifespan } - claims := model.Claims{ + claims := &model.Claims{ Scopes: strings.Join(scopes, " "), Payload: payload, Type: tokenType, @@ -282,7 +282,7 @@ func (ts *JWTokenService) NewRefreshToken(u model.User, scopes []string, app mod lifespan = RefreshTokenLifespan } - claims := model.Claims{ + claims := &model.Claims{ Scopes: strings.Join(scopes, " "), Payload: payload, Type: model.TokenTypeRefresh, @@ -418,7 +418,7 @@ func (ts *JWTokenService) NewResetToken(userID string) (model.Token, error) { lifespan := ts.resetTokenLifespan - claims := model.Claims{ + claims := &model.Claims{ Type: model.TokenTypeReset, StandardClaims: jwt.StandardClaims{ ExpiresAt: (now + lifespan), @@ -450,7 +450,7 @@ func (ts *JWTokenService) NewWebCookieToken(u model.User) (model.Token, error) { now := ijwt.TimeFunc().Unix() lifespan := ts.resetTokenLifespan - claims := model.Claims{ + claims := &model.Claims{ Type: model.TokenTypeWebCookie, StandardClaims: jwt.StandardClaims{ ExpiresAt: (now + lifespan), diff --git a/model/token.go b/model/token.go index b36eb981..1b12572d 100644 --- a/model/token.go +++ b/model/token.go @@ -38,7 +38,7 @@ type Token interface { } // NewTokenWithClaims generates new JWT token with claims and keyID. -func NewTokenWithClaims(method jwt.SigningMethod, kid string, claims jwt.Claims) *jwt.Token { +func NewTokenWithClaims(method jwt.SigningMethod, kid string, claims *Claims) *jwt.Token { return &jwt.Token{ Header: map[string]interface{}{ "typ": "JWT", diff --git a/web/api/federated_oidc_login.go b/web/api/federated_oidc_login.go index e07cc96a..60aef165 100644 --- a/web/api/federated_oidc_login.go +++ b/web/api/federated_oidc_login.go @@ -368,7 +368,7 @@ func (ar *Router) completeOIDCAuth( providerScope, ok := providerScopeVal.(string) if !ok { - ar.logger.Printf("oidc returned scope is not string but %T %+v", providerScope, providerScope) + ar.logger.Printf("oidc returned scope is not string but %T %+v", providerScopeVal, providerScopeVal) } // Extract the ID Token from OAuth2 token. diff --git a/web/api/federated_oidc_server_test.go b/web/api/federated_oidc_server_test.go index 6ce56b64..dfd900db 100644 --- a/web/api/federated_oidc_server_test.go +++ b/web/api/federated_oidc_server_test.go @@ -11,7 +11,6 @@ import ( jwt "github.com/golang-jwt/jwt/v4" ijwt "github.com/madappgang/identifo/v2/jwt" - "github.com/madappgang/identifo/v2/model" "github.com/madappgang/identifo/v2/web/api" ) @@ -65,15 +64,25 @@ func testOIDCServer() (*httptest.Server, context.CancelFunc) { }) mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) { - idt, err := model.NewTokenWithClaims(jwt.SigningMethodES256, "kid", jwt.MapClaims{ - "sub": "abc", - "emails": []string{"some@example.com"}, - "email": "some@example.com", - "iss": cfg.Issuer, - "aud": "test", - "exp": time.Now().Add(time.Hour).Unix(), - "iat": time.Now().Unix(), - }).SignedString(privateKey) + token := jwt.Token{ + Header: map[string]interface{}{ + "typ": "JWT", + "alg": jwt.SigningMethodES256.Alg(), + "kid": "kid", + }, + Claims: jwt.MapClaims{ + "sub": "abc", + "emails": []string{"some@example.com"}, + "email": "some@example.com", + "iss": cfg.Issuer, + "aud": "test", + "exp": time.Now().Add(time.Hour).Unix(), + "iat": time.Now().Unix(), + }, + Method: jwt.SigningMethodES256, + } + + idt, err := token.SignedString(privateKey) if err != nil { panic(err) }