diff --git a/.env.example b/.env.example index c4e7468594..7173356b77 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,7 @@ TRUST_PROXY=true AUTHGEAR_APP_ID=accounts AUTHGEAR_CLIENT_ID=portal AUTHGEAR_ENDPOINT=http://accounts.portal.localhost:3100 +AUTHGEAR_WEB_SDK_SESSION_TYPE=refresh_token # Use a pool size of 2 to make potential deadlock visible. # 1 connection is dedicated for LISTEN config source change. diff --git a/.vettedpositions b/.vettedpositions index 7e8e4a8efc..ea4d752b9b 100644 --- a/.vettedpositions +++ b/.vettedpositions @@ -297,7 +297,8 @@ /pkg/lib/session/test/context.go:85:35: requestcontext /pkg/lib/workflow/intl_middleware.go:16:41: requestcontext /pkg/portal/csp_middleware.go:16:39: requestcontext -/pkg/portal/session/middleware_session_info.go:19:37: requestcontext +/pkg/portal/session/middleware_session_info.go:54:9: requestcontext +/pkg/portal/session/middleware_session_info.go:175:36: requestcontext /pkg/portal/session/middleware_session_required.go:12:38: requestcontext /pkg/portal/transport/admin_api_handler.go:54:45: requestcontext /pkg/portal/transport/admin_api_handler.go:61:44: requestcontext diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f679ec0387..c2ba6280db 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,21 +1,25 @@ -- [Contributing guide](#contributing-guide) +* [Contributing guide](#contributing-guide) * [Install dependencies](#install-dependencies) - + [Install dependencies with asdf and homebrew](#install-dependencies-with-asdf-and-homebrew) - + [Install dependencies with Nix Flakes](#install-dependencies-with-nix-flakes) + * [Install dependencies with asdf and homebrew](#install-dependencies-with-asdf-and-homebrew) + * [Install dependencies with Nix Flakes](#install-dependencies-with-nix-flakes) * [Set up environment](#set-up-environment) * [Set up the database](#set-up-the-database) - * [Set up MinIO](#setup-minio) + * [Set up MinIO](#set-up-minio) * [Run](#run) * [Create an account for yourselves and grant you access to the portal](#create-an-account-for-yourselves-and-grant-you-access-to-the-portal) * [Known issues](#known-issues) - + [Known issues on portal](#known-issues-on-portal) + * [Known issues on portal](#known-issues-on-portal) * [Comment tags](#comment-tags) * [Common tasks](#common-tasks) - + [How to create a new database migration?](#how-to-create-a-new-database-migration) - + [Set up HTTPS to develop some specific features](#set-up-https-to-develop-some-specific-features) - + [Create release tag for a deployment](#create-release-tag-for-a-deployment) - + [Keep dependencies up-to-date](#keep-dependencies-up-to-date) - + [Generate Translation](#generate-translation) + * [How to create a new database migration?](#how-to-create-a-new-database-migration) + * [Set up HTTPS to develop some specific features](#set-up-https-to-develop-some-specific-features) + * [Create release tag for a deployment](#create-release-tag-for-a-deployment) + * [Keep dependencies up\-to\-date](#keep-dependencies-up-to-date) + * [Generate translation](#generate-translation) + * [Set up LDAP for local development](#set-up-ldap-for-local-development) + * [Create a LDAP user](#create-a-ldap-user) + * [Configure Authgear](#configure-authgear) + * [Start with the profile ldap](#start-with-the-profile-ldap) # Contributing guide @@ -159,11 +163,17 @@ use flake client_id: portal # Note that the trailing slash is very important here # URIs are compared byte by byte. + refresh_token_lifetime_seconds: 86400 + refresh_token_idle_timeout_enabled: true + refresh_token_idle_timeout_seconds: 1800 + issue_jwt_access_token: true + access_token_lifetime_seconds: 900 redirect_uris: - # This redirect URI is used by the portal development server. + # See nginx.conf for the difference between 8000, 8001, 8010, and 8011 - "http://portal.localhost:8000/oauth-redirect" - # This redirect URI is used by the portal production build. + - "http://portal.localhost:8001/oauth-redirect" - "http://portal.localhost:8010/oauth-redirect" + - "http://portal.localhost:8011/oauth-redirect" # This redirect URI is used by the iOS and Android demo app. - "com.authgear.example://host/path" # This redirect URI is used by the React Native demo app. @@ -177,9 +187,6 @@ use flake - "http://portal.localhost:8000/" # This redirect URI is used by the portal production build. - "http://portal.localhost:8010/" - grant_types: [] - response_types: - - none ``` 3. Set up `.localhost` @@ -212,6 +219,12 @@ use flake go run ./cmd/authgear search database migrate up ``` +3. Add domain + + ``` + go run ./cmd/portal internal domain create-custom accounts --apex-domain="accounts.portal.localhost" --domain="accounts.portal.localhost" + ``` + ## Set up MinIO ```sh @@ -485,3 +498,17 @@ To start them, you need to add `--profile ldap` to `docker compose up -d`, like ``` docker compose --profile ldap up -d ``` + +## Switching between sessionType=refresh_token and sessionType=cookie + +The default configuration + +- Accessing the portal at port 8000 or 8010 +- AUTHGEAR_WEB_SDK_SESSION_TYPE in .env.example + +assumes sessionType=refresh_token. + +In case you need to switch to sessionType=cookie, you + +- Use `AUTHGEAR_WEB_SDK_SESSION_TYPE=cookie` in your .env +- Access the portal at port 8001 or 8011 diff --git a/docker-compose.yaml b/docker-compose.yaml index 456fcb5a2b..516e565447 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -82,7 +82,9 @@ services: - ./tls-cert.pem:/etc/nginx/tls-cert.pem ports: - "8000:8000" + - "8001:8001" - "8010:8010" + - "8011:8011" - "3100:3100" - "443:443" diff --git a/nginx.conf b/nginx.conf index 40eb930266..e17eb7641b 100644 --- a/nginx.conf +++ b/nginx.conf @@ -74,11 +74,12 @@ http { proxy_pass http://host.docker.internal:3001/resolve; proxy_pass_request_body off; proxy_set_header Host $http_host; - proxy_set_header X-Forwarded-Host "accounts.localhost"; + proxy_set_header X-Forwarded-Host "accounts.portal.localhost:3100"; proxy_set_header Content-Length ""; } } + # vite dev server, sessionType=refresh_token server { server_name _; listen 8000; @@ -91,6 +92,98 @@ http { proxy_set_header Connection $connection_upgrade; } + location ~ ^/api { + proxy_pass http://host.docker.internal:3003; + proxy_http_version 1.1; + proxy_set_header Host $http_host; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + } + } + + # vite dev server, sessionType=cookie + server { + server_name _; + listen 8001; + + location / { + proxy_pass http://host.docker.internal:1234; + proxy_http_version 1.1; + proxy_set_header Host $http_host; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + } + + location ~ ^/api { + proxy_pass http://host.docker.internal:3003; + proxy_set_header Host $http_host; + + auth_request /_auth; + + auth_request_set $x_authgear_session_valid $upstream_http_x_authgear_session_valid; + auth_request_set $x_authgear_user_id $upstream_http_x_authgear_user_id; + auth_request_set $x_authgear_user_anonymous $upstream_http_x_authgear_user_anonymous; + auth_request_set $x_authgear_user_verified $upstream_http_x_authgear_user_verified; + auth_request_set $x_authgear_session_acr $upstream_http_x_authgear_session_acr; + auth_request_set $x_authgear_session_amr $upstream_http_x_authgear_session_amr; + auth_request_set $x_authgear_session_authenticated_at $upstream_http_x_authgear_session_authenticated_at; + auth_request_set $x_authgear_user_can_reauthenticate $upstream_http_x_authgear_user_can_reauthenticate; + + proxy_set_header x-authgear-session-valid $x_authgear_session_valid; + proxy_set_header x-authgear-user-id $x_authgear_user_id; + proxy_set_header x-authgear-user-anonymous $x_authgear_user_anonymous; + proxy_set_header x-authgear-user-verified $x_authgear_user_verified; + proxy_set_header x-authgear-session-acr $x_authgear_session_acr; + proxy_set_header x-authgear-session-amr $x_authgear_session_amr; + proxy_set_header x-authgear-session-authenticated-at $x_authgear_session_authenticated_at; + proxy_set_header x-authgear-user-can-reauthenticate $x_authgear_user_can_reauthenticate; + } + + location = /_auth { + internal; + proxy_pass http://host.docker.internal:3001/resolve; + proxy_pass_request_body off; + proxy_set_header Host $http_host; + proxy_set_header X-Forwarded-Host "accounts.portal.localhost:3100"; + proxy_set_header Content-Length ""; + } + } + + # portal production build, sessionType=refresh_token + server { + server_name _; + listen 8010; + + location / { + proxy_pass http://host.docker.internal:3003; + proxy_http_version 1.1; + proxy_set_header Host $http_host; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + } + + location ~ ^/api { + proxy_pass http://host.docker.internal:3003; + proxy_http_version 1.1; + proxy_set_header Host $http_host; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + } + } + + # portal production build, sessionType=cookie + server { + server_name _; + listen 8011; + + location / { + proxy_pass http://host.docker.internal:3003; + proxy_http_version 1.1; + proxy_set_header Host $http_host; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + } + location ~ ^/api { proxy_pass http://host.docker.internal:3003; proxy_set_header Host $http_host; @@ -121,7 +214,7 @@ http { proxy_pass http://host.docker.internal:3001/resolve; proxy_pass_request_body off; proxy_set_header Host $http_host; - proxy_set_header X-Forwarded-Host "accounts.localhost"; + proxy_set_header X-Forwarded-Host "accounts.portal.localhost:3100"; proxy_set_header Content-Length ""; } } diff --git a/pkg/admin/facade/oauth.go b/pkg/admin/facade/oauth.go index 6352210e26..cbd089206a 100644 --- a/pkg/admin/facade/oauth.go +++ b/pkg/admin/facade/oauth.go @@ -31,13 +31,7 @@ type OAuthTokenService interface { ) (offlineGrant *oauth.OfflineGrant, tokenHash string, err error) IssueAccessGrant( ctx context.Context, - client *config.OAuthClientConfig, - scopes []string, - authzID string, - userID string, - sessionID string, - sessionKind oauth.GrantSessionKind, - refreshTokenHash string, + options oauth.IssueAccessGrantOptions, resp protocol.TokenResponse, ) error } @@ -108,13 +102,14 @@ func (f *OAuthFacade) CreateSession(ctx context.Context, clientID string, userID err = f.Tokens.IssueAccessGrant( ctx, - client, - scopes, - authz.ID, - authz.UserID, - offlineGrant.ID, - oauth.GrantSessionKindOffline, - tokenHash, + oauth.IssueAccessGrantOptions{ + ClientConfig: client, + Scopes: scopes, + AuthorizationID: authz.ID, + AuthenticationInfo: offlineGrant.GetAuthenticationInfo(), + SessionLike: offlineGrant, + RefreshTokenHash: tokenHash, + }, resp, ) if err != nil { diff --git a/pkg/auth/handler/saml/logout.go b/pkg/auth/handler/saml/logout.go index 9367be11f3..0f4ce4b644 100644 --- a/pkg/auth/handler/saml/logout.go +++ b/pkg/auth/handler/saml/logout.go @@ -9,7 +9,7 @@ import ( "github.com/authgear/authgear-server/pkg/lib/config" "github.com/authgear/authgear-server/pkg/lib/infra/db/appdb" - "github.com/authgear/authgear-server/pkg/lib/oauth/oidc" + "github.com/authgear/authgear-server/pkg/lib/oauth" "github.com/authgear/authgear-server/pkg/lib/saml" "github.com/authgear/authgear-server/pkg/lib/saml/samlbinding" "github.com/authgear/authgear-server/pkg/lib/saml/samlprotocol" @@ -405,7 +405,7 @@ func (h *LogoutHandler) invalidateSession( affectedServiceProviderIDs setutil.Set[string], err error, ) { - _, sessionID, ok := oidc.DecodeSID(sid) + _, sessionID, ok := oauth.DecodeSID(sid) if ok { s, err := h.SessionManager.Get(ctx, sessionID) if err != nil { diff --git a/pkg/auth/handler/webapp/logout.go b/pkg/auth/handler/webapp/logout.go index 110bd88b8a..1ad108ed40 100644 --- a/pkg/auth/handler/webapp/logout.go +++ b/pkg/auth/handler/webapp/logout.go @@ -8,7 +8,7 @@ import ( "github.com/authgear/authgear-server/pkg/auth/webapp" "github.com/authgear/authgear-server/pkg/lib/config" "github.com/authgear/authgear-server/pkg/lib/infra/db/appdb" - "github.com/authgear/authgear-server/pkg/lib/oauth/oidc" + "github.com/authgear/authgear-server/pkg/lib/oauth" "github.com/authgear/authgear-server/pkg/lib/saml/samlslosession" "github.com/authgear/authgear-server/pkg/lib/session" "github.com/authgear/authgear-server/pkg/lib/uiparam" @@ -101,7 +101,7 @@ func (h *LogoutHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { if len(pendingLogoutServiceProviderIDs.Keys()) > 0 { sloSessionEntry := &samlslosession.SAMLSLOSessionEntry{ PendingLogoutServiceProviderIDs: pendingLogoutServiceProviderIDs.Keys(), - SID: oidc.EncodeSID(sess), + SID: oauth.EncodeSID(sess), UserID: sess.GetAuthenticationInfo().UserID, PostLogoutRedirectURI: redirectURI, } diff --git a/pkg/lib/oauth/grant.go b/pkg/lib/oauth/grant.go index c798df49d2..426214721b 100644 --- a/pkg/lib/oauth/grant.go +++ b/pkg/lib/oauth/grant.go @@ -1,8 +1,36 @@ package oauth +import ( + "fmt" + + "github.com/authgear/authgear-server/pkg/lib/session" +) + type GrantSessionKind string const ( GrantSessionKindOffline GrantSessionKind = "offline_grant" GrantSessionKindSession GrantSessionKind = "idp_session" ) + +func (k GrantSessionKind) SessionType() session.Type { + switch k { + case GrantSessionKindSession: + return session.TypeIdentityProvider + case GrantSessionKindOffline: + return session.TypeOfflineGrant + default: + panic(fmt.Errorf("unknown session kind: %v\n", k)) + } +} + +func GrantSessionKindFromSessionType(typ session.Type) GrantSessionKind { + switch typ { + case session.TypeIdentityProvider: + return GrantSessionKindSession + case session.TypeOfflineGrant: + return GrantSessionKindOffline + default: + panic(fmt.Errorf("unknown session type: %v", typ)) + } +} diff --git a/pkg/lib/oauth/grant_access_service.go b/pkg/lib/oauth/grant_access_service.go index 62f7a06f25..7dc91cfe60 100644 --- a/pkg/lib/oauth/grant_access_service.go +++ b/pkg/lib/oauth/grant_access_service.go @@ -3,6 +3,7 @@ package oauth import ( "context" + "github.com/authgear/authgear-server/pkg/lib/authn/authenticationinfo" "github.com/authgear/authgear-server/pkg/lib/config" "github.com/authgear/authgear-server/pkg/util/clock" ) @@ -15,6 +16,15 @@ type AccessGrantService struct { Clock clock.Clock } +type IssueAccessGrantOptions struct { + ClientConfig *config.OAuthClientConfig + Scopes []string + AuthorizationID string + AuthenticationInfo authenticationinfo.T + SessionLike SessionLike + RefreshTokenHash string +} + type IssueAccessGrantResult struct { Token string TokenType string @@ -23,35 +33,35 @@ type IssueAccessGrantResult struct { func (s *AccessGrantService) IssueAccessGrant( ctx context.Context, - client *config.OAuthClientConfig, - scopes []string, - authzID string, - userID string, - sessionID string, - sessionKind GrantSessionKind, - refreshTokenHash string, + options IssueAccessGrantOptions, ) (*IssueAccessGrantResult, error) { token := GenerateToken() now := s.Clock.NowUTC() accessGrant := &AccessGrant{ AppID: string(s.AppID), - AuthorizationID: authzID, - SessionID: sessionID, - SessionKind: sessionKind, + AuthorizationID: options.AuthorizationID, + SessionID: options.SessionLike.SessionID(), + SessionKind: GrantSessionKindFromSessionType(options.SessionLike.SessionType()), CreatedAt: now, - ExpireAt: now.Add(client.AccessTokenLifetime.Duration()), - Scopes: scopes, + ExpireAt: now.Add(options.ClientConfig.AccessTokenLifetime.Duration()), + Scopes: options.Scopes, TokenHash: HashToken(token), - RefreshTokenHash: refreshTokenHash, + RefreshTokenHash: options.RefreshTokenHash, } err := s.AccessGrants.CreateAccessGrant(ctx, accessGrant) if err != nil { return nil, err } - clientLike := ClientClientLike(client, scopes) - at, err := s.AccessTokenIssuer.EncodeAccessToken(ctx, client, clientLike, accessGrant, userID, token) + clientLike := ClientClientLike(options.ClientConfig, options.Scopes) + at, err := s.AccessTokenIssuer.EncodeAccessToken(ctx, EncodeAccessTokenOptions{ + OriginalToken: token, + ClientConfig: options.ClientConfig, + ClientLike: clientLike, + AccessGrant: accessGrant, + AuthenticationInfo: options.AuthenticationInfo, + }) if err != nil { return nil, err } @@ -59,7 +69,7 @@ func (s *AccessGrantService) IssueAccessGrant( result := &IssueAccessGrantResult{ Token: at, TokenType: "Bearer", - ExpiresIn: int(client.AccessTokenLifetime), + ExpiresIn: int(options.ClientConfig.AccessTokenLifetime), } return result, nil diff --git a/pkg/lib/oauth/handler/handler_anonymous_user.go b/pkg/lib/oauth/handler/handler_anonymous_user.go index ee6d1d68fe..29cf1a5c57 100644 --- a/pkg/lib/oauth/handler/handler_anonymous_user.go +++ b/pkg/lib/oauth/handler/handler_anonymous_user.go @@ -174,8 +174,15 @@ func (h *AnonymousUserHandler) signupAnonymousUserWithRefreshTokenSessionType( } resp := protocol.TokenResponse{} - err = h.TokenService.IssueAccessGrant(ctx, client, scopes, authz.ID, authz.UserID, - grant.ID, oauth.GrantSessionKindOffline, refreshTokenHash, resp) + issueAccessGrantOptions := oauth.IssueAccessGrantOptions{ + ClientConfig: client, + Scopes: scopes, + AuthorizationID: authz.ID, + AuthenticationInfo: grant.GetAuthenticationInfo(), + SessionLike: grant, + RefreshTokenHash: refreshTokenHash, + } + err = h.TokenService.IssueAccessGrant(ctx, issueAccessGrantOptions, resp) if err != nil { return nil, err } @@ -222,8 +229,15 @@ func (h *AnonymousUserHandler) signupAnonymousUserWithRefreshTokenSessionType( return nil, err } - err = h.TokenService.IssueAccessGrant(ctx, client, scopes, authz.ID, authz.UserID, - offlineGrant.ID, oauth.GrantSessionKindOffline, tokenHash, resp) + issueAccessGrantOptions := oauth.IssueAccessGrantOptions{ + ClientConfig: client, + Scopes: scopes, + AuthorizationID: authz.ID, + AuthenticationInfo: info, + SessionLike: offlineGrant, + RefreshTokenHash: tokenHash, + } + err = h.TokenService.IssueAccessGrant(ctx, issueAccessGrantOptions, resp) if err != nil { return nil, err } diff --git a/pkg/lib/oauth/handler/handler_authz.go b/pkg/lib/oauth/handler/handler_authz.go index ee1a983a1f..aef6132fff 100644 --- a/pkg/lib/oauth/handler/handler_authz.go +++ b/pkg/lib/oauth/handler/handler_authz.go @@ -536,7 +536,7 @@ func (h *AuthorizationHandler) doHandlePreAuthenticatedURL( if sid, ok = sidInt.(string); !ok { return nil, protocol.NewError("invalid_request", "sid is not a string in id_token_hint") } - _, sessionID, ok := oidc.DecodeSID(sid) + _, sessionID, ok := oauth.DecodeSID(sid) if !ok { return nil, protocol.NewError("invalid_request", "invalid sid format id_token_hint") } diff --git a/pkg/lib/oauth/handler/handler_authz_test.go b/pkg/lib/oauth/handler/handler_authz_test.go index 4607cc2f44..c1815b635f 100644 --- a/pkg/lib/oauth/handler/handler_authz_test.go +++ b/pkg/lib/oauth/handler/handler_authz_test.go @@ -486,7 +486,7 @@ func TestAuthorizationHandler(t *testing.T) { testOfflineGrant := &oauth.OfflineGrant{ ID: testOfflineGrantID, } - testSID := oidc.EncodeSID(testOfflineGrant) + testSID := oauth.EncodeSID(testOfflineGrant) // nolint:gosec testPreAuthenticatedURLToken := "TEST_PRE_AUTHENTICATED_URL_TOKEN" diff --git a/pkg/lib/oauth/handler/handler_token.go b/pkg/lib/oauth/handler/handler_token.go index e023e3c76d..7b24faf757 100644 --- a/pkg/lib/oauth/handler/handler_token.go +++ b/pkg/lib/oauth/handler/handler_token.go @@ -95,7 +95,7 @@ type IDTokenIssuer interface { } type AccessTokenIssuer interface { - EncodeAccessToken(ctx context.Context, client *config.OAuthClientConfig, clientLike *oauth.ClientLike, grant *oauth.AccessGrant, userID string, token string) (string, error) + EncodeAccessToken(ctx context.Context, options oauth.EncodeAccessTokenOptions) (string, error) } type EventService interface { @@ -161,13 +161,7 @@ type TokenHandlerTokenService interface { ParseRefreshToken(ctx context.Context, token string) (authz *oauth.Authorization, offlineGrant *oauth.OfflineGrant, tokenHash string, err error) IssueAccessGrant( ctx context.Context, - client *config.OAuthClientConfig, - scopes []string, - authzID string, - userID string, - sessionID string, - sessionKind oauth.GrantSessionKind, - refreshTokenHash string, + options oauth.IssueAccessGrantOptions, resp protocol.TokenResponse, ) error IssueOfflineGrant( @@ -205,6 +199,19 @@ type PreAuthenticatedURLTokenService interface { ) (string, error) } +type SimpleSessionLike struct { + ID string + GrantSessionKind oauth.GrantSessionKind +} + +func (s SimpleSessionLike) SessionID() string { + return s.ID +} + +func (s SimpleSessionLike) SessionType() session.Type { + return s.GrantSessionKind.SessionType() +} + type TokenHandler struct { AppID config.AppID AppDomains config.AppDomains @@ -687,7 +694,7 @@ func (h *TokenHandler) resolveIDTokenSession(ctx context.Context, idToken jwt.To return nil, false, nil } - typ, sessionID, ok := oidc.DecodeSID(sid) + typ, sessionID, ok := oauth.DecodeSID(sid) if !ok { return nil, false, nil } @@ -835,7 +842,7 @@ func (h *TokenHandler) handlePreAuthenticatedURLToken( // Issue new id_token which associated to the new device_secret newIDToken, err := h.IDTokenIssuer.IssueIDToken(ctx, oidc.IssueIDTokenOptions{ ClientID: client.ClientID, - SID: oidc.EncodeSID(offlineGrant), + SID: oauth.EncodeSID(offlineGrant), AuthenticationInfo: offlineGrant.GetAuthenticationInfo(), // scopes are used for specifying which fields should be included in the ID token // those fields may include personal identifiable information @@ -971,8 +978,15 @@ func (h *TokenHandler) handleAnonymousRequest( return nil, err } - err = h.TokenService.IssueAccessGrant(ctx, client, scopes, authz.ID, authz.UserID, - offlineGrant.ID, oauth.GrantSessionKindOffline, tokenHash, resp) + issueAccessGrantOptions := oauth.IssueAccessGrantOptions{ + ClientConfig: client, + Scopes: scopes, + AuthorizationID: authz.ID, + AuthenticationInfo: offlineGrant.GetAuthenticationInfo(), + SessionLike: offlineGrant, + RefreshTokenHash: tokenHash, + } + err = h.TokenService.IssueAccessGrant(ctx, issueAccessGrantOptions, resp) if err != nil { err = h.translateAccessTokenError(err) return nil, err @@ -981,7 +995,7 @@ func (h *TokenHandler) handleAnonymousRequest( if slice.ContainsString(scopes, "openid") { idToken, err := h.IDTokenIssuer.IssueIDToken(ctx, oidc.IssueIDTokenOptions{ ClientID: client.ClientID, - SID: oidc.EncodeSID(offlineGrant), + SID: oauth.EncodeSID(offlineGrant), AuthenticationInfo: offlineGrant.GetAuthenticationInfo(), ClientLike: oauth.ClientClientLike(client, scopes), DeviceSecretHash: offlineGrant.DeviceSecretHash, @@ -1228,8 +1242,15 @@ func (h *TokenHandler) handleBiometricAuthenticate( return nil, err } - err = h.TokenService.IssueAccessGrant(ctx, client, scopes, authz.ID, authz.UserID, - offlineGrant.ID, oauth.GrantSessionKindOffline, tokenHash, resp) + issueAccessGrantOptions := oauth.IssueAccessGrantOptions{ + ClientConfig: client, + Scopes: scopes, + AuthorizationID: authz.ID, + AuthenticationInfo: offlineGrant.GetAuthenticationInfo(), + SessionLike: offlineGrant, + RefreshTokenHash: tokenHash, + } + err = h.TokenService.IssueAccessGrant(ctx, issueAccessGrantOptions, resp) if err != nil { err = h.translateAccessTokenError(err) return nil, err @@ -1238,7 +1259,7 @@ func (h *TokenHandler) handleBiometricAuthenticate( if slice.ContainsString(scopes, "openid") { idToken, err := h.IDTokenIssuer.IssueIDToken(ctx, oidc.IssueIDTokenOptions{ ClientID: client.ClientID, - SID: oidc.EncodeSID(offlineGrant), + SID: oauth.EncodeSID(offlineGrant), AuthenticationInfo: offlineGrant.GetAuthenticationInfo(), ClientLike: oauth.ClientClientLike(client, scopes), DeviceSecretHash: offlineGrant.DeviceSecretHash, @@ -1428,7 +1449,7 @@ func (h *TokenHandler) handleIDToken( } idToken, err := h.IDTokenIssuer.IssueIDToken(ctx, oidc.IssueIDTokenOptions{ ClientID: client.ClientID, - SID: oidc.EncodeSID(s), + SID: oauth.EncodeSID(s), AuthenticationInfo: s.GetAuthenticationInfo(), // scopes are used for specifying which fields should be included in the ID token // those fields may include personal identifiable information @@ -1535,7 +1556,7 @@ func (h *TokenHandler) doIssueTokensForAuthorizationCode( // Reauth // Update auth_time, app2app device key and device_secret of the offline grant if possible. if sid := code.IDTokenHintSID; sid != "" { - if typ, sessionID, ok := oidc.DecodeSID(sid); ok && typ == session.TypeOfflineGrant { + if typ, sessionID, ok := oauth.DecodeSID(sid); ok && typ == session.TypeOfflineGrant { offlineGrant, err := h.OfflineGrantService.GetOfflineGrant(ctx, sessionID) if err == nil { // Update auth_time @@ -1627,7 +1648,7 @@ func (h *TokenHandler) doIssueTokensForAuthorizationCode( } } - sid = oidc.EncodeSID(offlineGrant) + sid = oauth.EncodeSID(offlineGrant) accessTokenSessionID = offlineGrant.ID accessTokenSessionKind = oauth.GrantSessionKindOffline refreshTokenHash = tokenHash @@ -1651,7 +1672,7 @@ func (h *TokenHandler) doIssueTokensForAuthorizationCode( } } else if code.IDTokenHintSID != "" { sid = code.IDTokenHintSID - if typ, sessionID, ok := oidc.DecodeSID(sid); ok { + if typ, sessionID, ok := oauth.DecodeSID(sid); ok { accessTokenSessionID = sessionID switch typ { case session.TypeOfflineGrant: @@ -1681,7 +1702,7 @@ func (h *TokenHandler) doIssueTokensForAuthorizationCode( if err != nil { return nil, err } - sid = oidc.EncodeSID(offlineGrant) + sid = oauth.EncodeSID(offlineGrant) accessTokenSessionID = offlineGrant.ID accessTokenSessionKind = oauth.GrantSessionKindOffline } @@ -1690,15 +1711,20 @@ func (h *TokenHandler) doIssueTokensForAuthorizationCode( return nil, protocol.NewError("invalid_request", "cannot issue access token") } + issueAccessGrantOptions := oauth.IssueAccessGrantOptions{ + ClientConfig: client, + Scopes: code.AuthorizationRequest.Scope(), + AuthorizationID: authz.ID, + AuthenticationInfo: info, + SessionLike: SimpleSessionLike{ + ID: accessTokenSessionID, + GrantSessionKind: accessTokenSessionKind, + }, + RefreshTokenHash: refreshTokenHash, + } err := h.TokenService.IssueAccessGrant( ctx, - client, - code.AuthorizationRequest.Scope(), - authz.ID, - authz.UserID, - accessTokenSessionID, - accessTokenSessionKind, - refreshTokenHash, + issueAccessGrantOptions, resp) if err != nil { err = h.translateAccessTokenError(err) @@ -1754,7 +1780,7 @@ func (h *TokenHandler) issueTokensForRefreshToken( if issueIDToken { idToken, err := h.IDTokenIssuer.IssueIDToken(ctx, oidc.IssueIDTokenOptions{ ClientID: client.ClientID, - SID: oidc.EncodeSID(offlineGrantSession.OfflineGrant), + SID: oauth.EncodeSID(offlineGrantSession.OfflineGrant), AuthenticationInfo: offlineGrantSession.GetAuthenticationInfo(), ClientLike: oauth.ClientClientLike(client, authz.Scopes), DeviceSecretHash: offlineGrant.DeviceSecretHash, @@ -1765,9 +1791,15 @@ func (h *TokenHandler) issueTokensForRefreshToken( resp.IDToken(idToken) } - err = h.TokenService.IssueAccessGrant(ctx, client, offlineGrantSession.Scopes, - authz.ID, authz.UserID, offlineGrantSession.SessionID(), - oauth.GrantSessionKindOffline, offlineGrantSession.TokenHash, resp) + issueAccessGrantOptions := oauth.IssueAccessGrantOptions{ + ClientConfig: client, + Scopes: offlineGrantSession.Scopes, + AuthorizationID: authz.ID, + AuthenticationInfo: offlineGrantSession.GetAuthenticationInfo(), + SessionLike: offlineGrantSession, + RefreshTokenHash: offlineGrantSession.TokenHash, + } + err = h.TokenService.IssueAccessGrant(ctx, issueAccessGrantOptions, resp) if err != nil { err = h.translateAccessTokenError(err) return nil, err diff --git a/pkg/lib/oauth/handler/handler_token_mock_test.go b/pkg/lib/oauth/handler/handler_token_mock_test.go index 88a9ca0450..6e17e6efdb 100644 --- a/pkg/lib/oauth/handler/handler_token_mock_test.go +++ b/pkg/lib/oauth/handler/handler_token_mock_test.go @@ -116,18 +116,18 @@ func (m *MockAccessTokenIssuer) EXPECT() *MockAccessTokenIssuerMockRecorder { } // EncodeAccessToken mocks base method. -func (m *MockAccessTokenIssuer) EncodeAccessToken(ctx context.Context, client *config.OAuthClientConfig, clientLike *oauth.ClientLike, grant *oauth.AccessGrant, userID, token string) (string, error) { +func (m *MockAccessTokenIssuer) EncodeAccessToken(ctx context.Context, options oauth.EncodeAccessTokenOptions) (string, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "EncodeAccessToken", ctx, client, clientLike, grant, userID, token) + ret := m.ctrl.Call(m, "EncodeAccessToken", ctx, options) ret0, _ := ret[0].(string) ret1, _ := ret[1].(error) return ret0, ret1 } // EncodeAccessToken indicates an expected call of EncodeAccessToken. -func (mr *MockAccessTokenIssuerMockRecorder) EncodeAccessToken(ctx, client, clientLike, grant, userID, token interface{}) *gomock.Call { +func (mr *MockAccessTokenIssuerMockRecorder) EncodeAccessToken(ctx, options interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "EncodeAccessToken", reflect.TypeOf((*MockAccessTokenIssuer)(nil).EncodeAccessToken), ctx, client, clientLike, grant, userID, token) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "EncodeAccessToken", reflect.TypeOf((*MockAccessTokenIssuer)(nil).EncodeAccessToken), ctx, options) } // MockEventService is a mock of EventService interface. @@ -641,17 +641,17 @@ func (m *MockTokenHandlerTokenService) EXPECT() *MockTokenHandlerTokenServiceMoc } // IssueAccessGrant mocks base method. -func (m *MockTokenHandlerTokenService) IssueAccessGrant(ctx context.Context, client *config.OAuthClientConfig, scopes []string, authzID, userID, sessionID string, sessionKind oauth.GrantSessionKind, refreshTokenHash string, resp protocol.TokenResponse) error { +func (m *MockTokenHandlerTokenService) IssueAccessGrant(ctx context.Context, options oauth.IssueAccessGrantOptions, resp protocol.TokenResponse) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "IssueAccessGrant", ctx, client, scopes, authzID, userID, sessionID, sessionKind, refreshTokenHash, resp) + ret := m.ctrl.Call(m, "IssueAccessGrant", ctx, options, resp) ret0, _ := ret[0].(error) return ret0 } // IssueAccessGrant indicates an expected call of IssueAccessGrant. -func (mr *MockTokenHandlerTokenServiceMockRecorder) IssueAccessGrant(ctx, client, scopes, authzID, userID, sessionID, sessionKind, refreshTokenHash, resp interface{}) *gomock.Call { +func (mr *MockTokenHandlerTokenServiceMockRecorder) IssueAccessGrant(ctx, options, resp interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IssueAccessGrant", reflect.TypeOf((*MockTokenHandlerTokenService)(nil).IssueAccessGrant), ctx, client, scopes, authzID, userID, sessionID, sessionKind, refreshTokenHash, resp) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IssueAccessGrant", reflect.TypeOf((*MockTokenHandlerTokenService)(nil).IssueAccessGrant), ctx, options, resp) } // IssueDeviceSecret mocks base method. diff --git a/pkg/lib/oauth/handler/handler_token_test.go b/pkg/lib/oauth/handler/handler_token_test.go index ced43f69ad..8ec88b8527 100644 --- a/pkg/lib/oauth/handler/handler_token_test.go +++ b/pkg/lib/oauth/handler/handler_token_test.go @@ -16,7 +16,6 @@ import ( "github.com/authgear/authgear-server/pkg/lib/config" "github.com/authgear/authgear-server/pkg/lib/oauth" "github.com/authgear/authgear-server/pkg/lib/oauth/handler" - "github.com/authgear/authgear-server/pkg/lib/oauth/oidc" "github.com/authgear/authgear-server/pkg/lib/oauth/protocol" "github.com/authgear/authgear-server/pkg/lib/session" "github.com/authgear/authgear-server/pkg/lib/session/access" @@ -103,7 +102,7 @@ func TestTokenHandler(t *testing.T) { } tokenService.EXPECT().ParseRefreshToken(gomock.Any(), "asdf").Return(&oauth.Authorization{}, offlineGrant, refreshTokenHash, nil) idTokenIssuer.EXPECT().IssueIDToken(gomock.Any(), gomock.Any()).Return("id-token", nil) - tokenService.EXPECT().IssueAccessGrant(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil) + tokenService.EXPECT().IssueAccessGrant(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil) event := access.NewEvent(clock.NowUTC(), "1.2.3.4", "UA") offlineGrantService.EXPECT().AccessOfflineGrant(gomock.Any(), "offline-grant-id", &event, offlineGrant.ExpireAtForResolvedSession).Return(offlineGrant, nil) offlineGrants.EXPECT().UpdateOfflineGrantDeviceInfo(gomock.Any(), "offline-grant-id", gomock.Any(), offlineGrant.ExpireAtForResolvedSession).Return(offlineGrant, nil) @@ -149,7 +148,7 @@ func TestTokenHandler(t *testing.T) { offlineGrantService.EXPECT().GetOfflineGrant(gomock.Any(), testOfflineGrantID). AnyTimes(). Return(testOfflineGrant, nil) - sid := oidc.EncodeSID(testOfflineGrant) + sid := oauth.EncodeSID(testOfflineGrant) mockIdToken := jwt.New() _ = mockIdToken.Set("iss", origin) _ = mockIdToken.Set("sid", sid) diff --git a/pkg/lib/oauth/handler/service_app_initiated_sso_to_web.go b/pkg/lib/oauth/handler/service_app_initiated_sso_to_web.go index 87e4bf5857..88406ee815 100644 --- a/pkg/lib/oauth/handler/service_app_initiated_sso_to_web.go +++ b/pkg/lib/oauth/handler/service_app_initiated_sso_to_web.go @@ -95,15 +95,17 @@ func (s *PreAuthenticatedURLTokenServiceImpl) ExchangeForAccessToken( } offlineGrant = newOfflineGrant + issueAccessGrantOptions := oauth.IssueAccessGrantOptions{ + ClientConfig: client, + Scopes: tokenModel.Scopes, + AuthorizationID: tokenModel.AuthorizationID, + AuthenticationInfo: offlineGrant.GetAuthenticationInfo(), + SessionLike: offlineGrant, + RefreshTokenHash: newRefreshTokenResult.TokenHash, + } result, err := s.AccessGrantService.IssueAccessGrant( ctx, - client, - tokenModel.Scopes, - tokenModel.AuthorizationID, - offlineGrant.GetUserID(), - offlineGrant.ID, - oauth.GrantSessionKindOffline, - newRefreshTokenResult.TokenHash, + issueAccessGrantOptions, ) if err != nil { diff --git a/pkg/lib/oauth/handler/service_token.go b/pkg/lib/oauth/handler/service_token.go index c0f2c02761..de103f3d94 100644 --- a/pkg/lib/oauth/handler/service_token.go +++ b/pkg/lib/oauth/handler/service_token.go @@ -170,17 +170,11 @@ func (s *TokenService) IssueRefreshTokenForOfflineGrant( func (s *TokenService) IssueAccessGrant( ctx context.Context, - client *config.OAuthClientConfig, - scopes []string, - authzID string, - userID string, - sessionID string, - sessionKind oauth.GrantSessionKind, - refreshTokenHash string, + options oauth.IssueAccessGrantOptions, resp protocol.TokenResponse, ) error { result, err := s.AccessGrantService.IssueAccessGrant( - ctx, client, scopes, authzID, userID, sessionID, sessionKind, refreshTokenHash, + ctx, options, ) if err != nil { return err diff --git a/pkg/lib/oauth/oidc/id_token.go b/pkg/lib/oauth/oidc/id_token.go index 81e79d3cbd..9d04a55cd2 100644 --- a/pkg/lib/oauth/oidc/id_token.go +++ b/pkg/lib/oauth/oidc/id_token.go @@ -2,11 +2,8 @@ package oidc import ( "context" - "encoding/base64" "fmt" "net/url" - "strings" - "unicode/utf8" "github.com/lestrrat-go/jwx/v2/jwa" "github.com/lestrrat-go/jwx/v2/jwk" @@ -52,52 +49,6 @@ type IDTokenIssuer struct { // It can be short, since id_token_hint should accept expired ID tokens. const IDTokenValidDuration = duration.Short -type SessionLike interface { - SessionID() string - SessionType() session.Type -} - -func EncodeSID(s SessionLike) string { - return EncodeSIDByRawValues(s.SessionType(), s.SessionID()) -} - -func EncodeSIDByRawValues(sessionType session.Type, sessionID string) string { - raw := fmt.Sprintf("%s:%s", sessionType, sessionID) - return base64.RawURLEncoding.EncodeToString([]byte(raw)) -} - -func DecodeSID(sid string) (typ session.Type, sessionID string, ok bool) { - bytes, err := base64.RawURLEncoding.DecodeString(sid) - if err != nil { - return - } - - if !utf8.Valid(bytes) { - return - } - str := string(bytes) - - parts := strings.Split(str, ":") - if len(parts) != 2 { - return - } - - typStr := parts[0] - sessionID = parts[1] - switch typStr { - case string(session.TypeIdentityProvider): - typ = session.TypeIdentityProvider - case string(session.TypeOfflineGrant): - typ = session.TypeOfflineGrant - } - if typ == "" { - return - } - - ok = true - return -} - func (ti *IDTokenIssuer) GetPublicKeySet() (jwk.Set, error) { return jwk.PublicSetOf(ti.Secrets.Set) } @@ -106,12 +57,6 @@ func (ti *IDTokenIssuer) Iss() string { return ti.BaseURL.Origin().String() } -func (ti *IDTokenIssuer) updateTimeClaims(token jwt.Token) { - now := ti.Clock.NowUTC() - _ = token.Set(jwt.IssuedAtKey, now.Unix()) - _ = token.Set(jwt.ExpirationKey, now.Add(IDTokenValidDuration).Unix()) -} - func (ti *IDTokenIssuer) sign(token jwt.Token) (string, error) { jwk, _ := ti.Secrets.Set.Key(0) signed, err := jwtutil.Sign(token, jwa.RS256, jwk) @@ -135,29 +80,33 @@ func (ti *IDTokenIssuer) IssueIDToken(ctx context.Context, opts IssueIDTokenOpti info := opts.AuthenticationInfo - // Populate issuer. + // iss _ = claims.Set(jwt.IssuerKey, ti.Iss()) + // aud + _ = claims.Set(jwt.AudienceKey, opts.ClientID) + now := ti.Clock.NowUTC() + // iat + _ = claims.Set(jwt.IssuedAtKey, now.Unix()) + // exp + _ = claims.Set(jwt.ExpirationKey, now.Add(IDTokenValidDuration).Unix()) err := ti.PopulateUserClaimsInIDToken(ctx, claims, info.UserID, opts.ClientLike) if err != nil { return "", err } - // Populate client specific claims - _ = claims.Set(jwt.AudienceKey, opts.ClientID) - - // Populate Time specific claims - ti.updateTimeClaims(claims) - - // Populate session specific claims + // auth_time + _ = claims.Set(string(model.ClaimAuthTime), info.AuthenticatedAt.Unix()) if sid := opts.SID; sid != "" { + // sid _ = claims.Set(string(model.ClaimSID), sid) } - _ = claims.Set(string(model.ClaimAuthTime), info.AuthenticatedAt.Unix()) if amr := info.AMR; len(amr) > 0 { + // amr _ = claims.Set(string(model.ClaimAMR), amr) } if dshash := opts.DeviceSecretHash; dshash != "" { + // ds_hash _ = claims.Set(string(model.ClaimDeviceSecretHash), dshash) } @@ -332,7 +281,7 @@ func (r *IDTokenHintResolver) ResolveIDTokenHint(ctx context.Context, client *co return } - typ, sessionID, ok := DecodeSID(sid) + typ, sessionID, ok := oauth.DecodeSID(sid) if !ok { return } diff --git a/pkg/lib/oauth/oidc/id_token_mock_test.go b/pkg/lib/oauth/oidc/id_token_mock_test.go index 84d03ec673..a4ed1e913e 100644 --- a/pkg/lib/oauth/oidc/id_token_mock_test.go +++ b/pkg/lib/oauth/oidc/id_token_mock_test.go @@ -11,7 +11,6 @@ import ( model "github.com/authgear/authgear-server/pkg/api/model" oauth "github.com/authgear/authgear-server/pkg/lib/oauth" - session "github.com/authgear/authgear-server/pkg/lib/session" idpsession "github.com/authgear/authgear-server/pkg/lib/session/idpsession" accesscontrol "github.com/authgear/authgear-server/pkg/util/accesscontrol" gomock "github.com/golang/mock/gomock" @@ -131,57 +130,6 @@ func (mr *MockBaseURLProviderMockRecorder) Origin() *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Origin", reflect.TypeOf((*MockBaseURLProvider)(nil).Origin)) } -// MockSessionLike is a mock of SessionLike interface. -type MockSessionLike struct { - ctrl *gomock.Controller - recorder *MockSessionLikeMockRecorder -} - -// MockSessionLikeMockRecorder is the mock recorder for MockSessionLike. -type MockSessionLikeMockRecorder struct { - mock *MockSessionLike -} - -// NewMockSessionLike creates a new mock instance. -func NewMockSessionLike(ctrl *gomock.Controller) *MockSessionLike { - mock := &MockSessionLike{ctrl: ctrl} - mock.recorder = &MockSessionLikeMockRecorder{mock} - return mock -} - -// EXPECT returns an object that allows the caller to indicate expected use. -func (m *MockSessionLike) EXPECT() *MockSessionLikeMockRecorder { - return m.recorder -} - -// SessionID mocks base method. -func (m *MockSessionLike) SessionID() string { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "SessionID") - ret0, _ := ret[0].(string) - return ret0 -} - -// SessionID indicates an expected call of SessionID. -func (mr *MockSessionLikeMockRecorder) SessionID() *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SessionID", reflect.TypeOf((*MockSessionLike)(nil).SessionID)) -} - -// SessionType mocks base method. -func (m *MockSessionLike) SessionType() session.Type { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "SessionType") - ret0, _ := ret[0].(session.Type) - return ret0 -} - -// SessionType indicates an expected call of SessionType. -func (mr *MockSessionLikeMockRecorder) SessionType() *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SessionType", reflect.TypeOf((*MockSessionLike)(nil).SessionType)) -} - // MockIDTokenHintResolverIssuer is a mock of IDTokenHintResolverIssuer interface. type MockIDTokenHintResolverIssuer struct { ctrl *gomock.Controller diff --git a/pkg/lib/oauth/oidc/id_token_test.go b/pkg/lib/oauth/oidc/id_token_test.go index 2489899a45..039d862e06 100644 --- a/pkg/lib/oauth/oidc/id_token_test.go +++ b/pkg/lib/oauth/oidc/id_token_test.go @@ -49,47 +49,7 @@ eZDnqWNf7mYPdP5mO5iTtMw= -----END PRIVATE KEY----- ` -type FakeSession struct { - ID string - Type session.Type -} - -func (s *FakeSession) SessionID() string { - return s.ID -} - -func (s *FakeSession) SessionType() session.Type { - return s.Type -} - -func TestSID(t *testing.T) { - Convey("EncodeSID and DecodeSID", t, func() { - s := &FakeSession{ - ID: "a", - Type: session.TypeIdentityProvider, - } - typ, sessionID, ok := DecodeSID(EncodeSID(s)) - So(typ, ShouldEqual, session.TypeIdentityProvider) - So(sessionID, ShouldEqual, "a") - So(ok, ShouldBeTrue) - - s = &FakeSession{ - ID: "b", - Type: session.TypeOfflineGrant, - } - typ, sessionID, ok = DecodeSID(EncodeSID(s)) - So(typ, ShouldEqual, session.TypeOfflineGrant) - So(sessionID, ShouldEqual, "b") - So(ok, ShouldBeTrue) - - s = &FakeSession{ - ID: "c", - Type: "nonsense", - } - _, _, ok = DecodeSID(EncodeSID(s)) - So(ok, ShouldBeFalse) - }) - +func TestIDTokenIssuer(t *testing.T) { Convey("IssueIDToken and VerifyIDToken", t, func() { ctrl := gomock.NewController(t) @@ -163,7 +123,7 @@ func TestSID(t *testing.T) { ctx := context.Background() idToken, err := issuer.IssueIDToken(ctx, IssueIDTokenOptions{ ClientID: "client-id", - SID: EncodeSID(offlineGrant), + SID: oauth.EncodeSID(offlineGrant), AuthenticationInfo: offlineGrant.GetAuthenticationInfo(), ClientLike: oauth.ClientClientLike(client, scopes), Nonce: "nonce-1", @@ -194,7 +154,7 @@ func TestSID(t *testing.T) { // Session claims encodedSessionID, _ := token.Get(string(model.ClaimSID)) - _, sessionID, _ := DecodeSID(encodedSessionID.(string)) + _, sessionID, _ := oauth.DecodeSID(encodedSessionID.(string)) So(sessionID, ShouldEqual, offlineGrant.ID) ds_hash, _ := token.Get(string(model.ClaimDeviceSecretHash)) diff --git a/pkg/lib/oauth/oidc/ui.go b/pkg/lib/oauth/oidc/ui.go index 98604e80d3..a44b02b0a6 100644 --- a/pkg/lib/oauth/oidc/ui.go +++ b/pkg/lib/oauth/oidc/ui.go @@ -180,7 +180,7 @@ func (r *UIInfoResolver) ResolveForAuthorizationEndpoint( var idTokenHintSID string if sidSession != nil { - idTokenHintSID = EncodeSID(sidSession) + idTokenHintSID = oauth.EncodeSID(sidSession) } var userIDHint string diff --git a/pkg/lib/oauth/pre_authenticated_url_token.go b/pkg/lib/oauth/pre_authenticated_url_token.go index 2895112a17..c5a44dfb3a 100644 --- a/pkg/lib/oauth/pre_authenticated_url_token.go +++ b/pkg/lib/oauth/pre_authenticated_url_token.go @@ -4,7 +4,6 @@ import ( "context" "time" - "github.com/authgear/authgear-server/pkg/lib/config" "github.com/authgear/authgear-server/pkg/util/duration" ) @@ -27,13 +26,7 @@ type PreAuthenticatedURLToken struct { type PreAuthenticatedURLTokenAccessGrantService interface { IssueAccessGrant( ctx context.Context, - client *config.OAuthClientConfig, - scopes []string, - authzID string, - userID string, - sessionID string, - sessionKind GrantSessionKind, - refreshTokenHash string, + options IssueAccessGrantOptions, ) (*IssueAccessGrantResult, error) } diff --git a/pkg/lib/oauth/sid.go b/pkg/lib/oauth/sid.go new file mode 100644 index 0000000000..55bb9348b5 --- /dev/null +++ b/pkg/lib/oauth/sid.go @@ -0,0 +1,56 @@ +package oauth + +import ( + "encoding/base64" + "fmt" + "strings" + "unicode/utf8" + + "github.com/authgear/authgear-server/pkg/lib/session" +) + +type SessionLike interface { + SessionID() string + SessionType() session.Type +} + +func EncodeSID(s SessionLike) string { + return EncodeSIDByRawValues(s.SessionType(), s.SessionID()) +} + +func EncodeSIDByRawValues(sessionType session.Type, sessionID string) string { + raw := fmt.Sprintf("%s:%s", sessionType, sessionID) + return base64.RawURLEncoding.EncodeToString([]byte(raw)) +} + +func DecodeSID(sid string) (typ session.Type, sessionID string, ok bool) { + bytes, err := base64.RawURLEncoding.DecodeString(sid) + if err != nil { + return + } + + if !utf8.Valid(bytes) { + return + } + str := string(bytes) + + parts := strings.Split(str, ":") + if len(parts) != 2 { + return + } + + typStr := parts[0] + sessionID = parts[1] + switch typStr { + case string(session.TypeIdentityProvider): + typ = session.TypeIdentityProvider + case string(session.TypeOfflineGrant): + typ = session.TypeOfflineGrant + } + if typ == "" { + return + } + + ok = true + return +} diff --git a/pkg/lib/oauth/sid_test.go b/pkg/lib/oauth/sid_test.go new file mode 100644 index 0000000000..7800809f30 --- /dev/null +++ b/pkg/lib/oauth/sid_test.go @@ -0,0 +1,51 @@ +package oauth + +import ( + "testing" + + . "github.com/smartystreets/goconvey/convey" + + "github.com/authgear/authgear-server/pkg/lib/session" +) + +type FakeSession struct { + ID string + Type session.Type +} + +func (s *FakeSession) SessionID() string { + return s.ID +} + +func (s *FakeSession) SessionType() session.Type { + return s.Type +} + +func TestSID(t *testing.T) { + Convey("EncodeSID and DecodeSID", t, func() { + s := &FakeSession{ + ID: "a", + Type: session.TypeIdentityProvider, + } + typ, sessionID, ok := DecodeSID(EncodeSID(s)) + So(typ, ShouldEqual, session.TypeIdentityProvider) + So(sessionID, ShouldEqual, "a") + So(ok, ShouldBeTrue) + + s = &FakeSession{ + ID: "b", + Type: session.TypeOfflineGrant, + } + typ, sessionID, ok = DecodeSID(EncodeSID(s)) + So(typ, ShouldEqual, session.TypeOfflineGrant) + So(sessionID, ShouldEqual, "b") + So(ok, ShouldBeTrue) + + s = &FakeSession{ + ID: "c", + Type: "nonsense", + } + _, _, ok = DecodeSID(EncodeSID(s)) + So(ok, ShouldBeFalse) + }) +} diff --git a/pkg/lib/oauth/token_encoding.go b/pkg/lib/oauth/token_encoding.go index bf33fc0ff7..0832da3325 100644 --- a/pkg/lib/oauth/token_encoding.go +++ b/pkg/lib/oauth/token_encoding.go @@ -16,6 +16,7 @@ import ( "github.com/authgear/authgear-server/pkg/api/event" "github.com/authgear/authgear-server/pkg/api/event/blocking" "github.com/authgear/authgear-server/pkg/api/model" + "github.com/authgear/authgear-server/pkg/lib/authn/authenticationinfo" "github.com/authgear/authgear-server/pkg/lib/authn/identity" "github.com/authgear/authgear-server/pkg/lib/config" "github.com/authgear/authgear-server/pkg/util/clock" @@ -50,34 +51,56 @@ type AccessTokenEncoding struct { Identities AccessTokenEncodingIdentityService } -func (e *AccessTokenEncoding) EncodeAccessToken(ctx context.Context, client *config.OAuthClientConfig, clientLike *ClientLike, grant *AccessGrant, userID string, token string) (string, error) { - if !client.IssueJWTAccessToken { - return token, nil +type EncodeAccessTokenOptions struct { + OriginalToken string + ClientConfig *config.OAuthClientConfig + ClientLike *ClientLike + AccessGrant *AccessGrant + AuthenticationInfo authenticationinfo.T +} + +func (e *AccessTokenEncoding) EncodeAccessToken(ctx context.Context, options EncodeAccessTokenOptions) (string, error) { + if !options.ClientConfig.IssueJWTAccessToken { + return options.OriginalToken, nil } claims := jwt.New() - err := e.IDTokenIssuer.PopulateUserClaimsInIDToken(ctx, claims, userID, clientLike) - if err != nil { - return "", err - } - + // iss _ = claims.Set(jwt.IssuerKey, e.IDTokenIssuer.Iss()) + // aud _ = claims.Set(jwt.AudienceKey, e.BaseURL.Origin().String()) - _ = claims.Set(jwt.IssuedAtKey, grant.CreatedAt.Unix()) - _ = claims.Set(jwt.ExpirationKey, grant.ExpireAt.Unix()) - _ = claims.Set("client_id", client.ClientID) + // iat + _ = claims.Set(jwt.IssuedAtKey, options.AccessGrant.CreatedAt.Unix()) + // exp + _ = claims.Set(jwt.ExpirationKey, options.AccessGrant.ExpireAt.Unix()) + // client_id + _ = claims.Set("client_id", options.ClientConfig.ClientID) + + // auth_time + _ = claims.Set(string(model.ClaimAuthTime), options.AuthenticationInfo.AuthenticatedAt.Unix()) + + // amr + if amr := options.AuthenticationInfo.AMR; len(amr) > 0 { + _ = claims.Set(string(model.ClaimAMR), amr) + } + // Do not put raw token in JWT access token; JWT payload is not specified // to be confidential. Put token hash to allow looking up access grant from // verified JWT. - _ = claims.Set(jwt.JwtIDKey, grant.TokenHash) + _ = claims.Set(jwt.JwtIDKey, options.AccessGrant.TokenHash) + + err := e.IDTokenIssuer.PopulateUserClaimsInIDToken(ctx, claims, options.AuthenticationInfo.UserID, options.ClientLike) + if err != nil { + return "", err + } forMutation, forBackup, err := jwtutil.PrepareForMutations(claims) if err != nil { return "", err } - identities, err := e.Identities.ListIdentitiesThatHaveStandardAttributes(ctx, userID) + identities, err := e.Identities.ListIdentitiesThatHaveStandardAttributes(ctx, options.AuthenticationInfo.UserID) if err != nil { return "", err } @@ -90,7 +113,7 @@ func (e *AccessTokenEncoding) EncodeAccessToken(ctx context.Context, client *con eventPayload := &blocking.OIDCJWTPreCreateBlockingEventPayload{ UserRef: model.UserRef{ Meta: model.Meta{ - ID: userID, + ID: options.AuthenticationInfo.UserID, }, }, Identities: identityModels, diff --git a/pkg/lib/oauth/token_encoding_test.go b/pkg/lib/oauth/token_encoding_test.go index 653a2e1160..e685c499c6 100644 --- a/pkg/lib/oauth/token_encoding_test.go +++ b/pkg/lib/oauth/token_encoding_test.go @@ -10,6 +10,7 @@ import ( "github.com/lestrrat-go/jwx/v2/jwt" . "github.com/smartystreets/goconvey/convey" + "github.com/authgear/authgear-server/pkg/lib/authn/authenticationinfo" "github.com/authgear/authgear-server/pkg/lib/config" "github.com/authgear/authgear-server/pkg/lib/endpoints" "github.com/authgear/authgear-server/pkg/util/clock" @@ -98,7 +99,18 @@ func TestAccessToken(t *testing.T) { mockIdentityService.EXPECT().ListIdentitiesThatHaveStandardAttributes(gomock.Any(), "user-id").Return(nil, nil) ctx := context.Background() - accessToken, err := encoding.EncodeAccessToken(ctx, client, clientLike, accessGrant, "user-id", "token") + options := EncodeAccessTokenOptions{ + OriginalToken: "token", + ClientConfig: client, + ClientLike: clientLike, + AccessGrant: accessGrant, + AuthenticationInfo: authenticationinfo.T{ + UserID: "user-id", + // AMR + // AuthenticatedAt + }, + } + accessToken, err := encoding.EncodeAccessToken(ctx, options) So(err, ShouldBeNil) _, _, err = encoding.DecodeAccessToken(accessToken) diff --git a/pkg/lib/saml/service.go b/pkg/lib/saml/service.go index 93114f467e..a196e7c9b4 100644 --- a/pkg/lib/saml/service.go +++ b/pkg/lib/saml/service.go @@ -17,7 +17,6 @@ import ( "github.com/authgear/authgear-server/pkg/lib/authn/authenticationinfo" "github.com/authgear/authgear-server/pkg/lib/config" "github.com/authgear/authgear-server/pkg/lib/oauth" - "github.com/authgear/authgear-server/pkg/lib/oauth/oidc" "github.com/authgear/authgear-server/pkg/lib/saml/samlprotocol" "github.com/authgear/authgear-server/pkg/lib/saml/samlslosession" "github.com/authgear/authgear-server/pkg/lib/session" @@ -293,7 +292,7 @@ func (s *Service) IssueLoginSuccessResponse( return nil, samlprotocol.ErrServiceProviderNotFound } authenticatedUserId := authInfo.UserID - sid := oidc.EncodeSIDByRawValues( + sid := oauth.EncodeSIDByRawValues( session.Type(authInfo.AuthenticatedBySessionType), authInfo.AuthenticatedBySessionID, ) diff --git a/pkg/portal/config/authgear.go b/pkg/portal/config/authgear.go index 60a99d7c2f..0e138d691f 100644 --- a/pkg/portal/config/authgear.go +++ b/pkg/portal/config/authgear.go @@ -4,4 +4,6 @@ type AuthgearConfig struct { ClientID string `envconfig:"CLIENT_ID"` Endpoint string `envconfig:"ENDPOINT"` AppID string `envconfig:"APP_ID"` + + WebSDKSessionType string `envconfig:"WEB_SDK_SESSION_TYPE" default:"cookie"` } diff --git a/pkg/portal/model/system_config.go b/pkg/portal/model/system_config.go index 24c48528a6..112bc7b648 100644 --- a/pkg/portal/model/system_config.go +++ b/pkg/portal/model/system_config.go @@ -5,20 +5,21 @@ import ( ) type SystemConfig struct { - AuthgearClientID string `json:"authgearClientID"` - AuthgearEndpoint string `json:"authgearEndpoint"` - SentryDSN string `json:"sentryDSN,omitempty"` - AppHostSuffix string `json:"appHostSuffix"` - AvailableLanguages []string `json:"availableLanguages"` - BuiltinLanguages []string `json:"builtinLanguages"` - Themes interface{} `json:"themes,omitempty"` - Translations interface{} `json:"translations,omitempty"` - SearchEnabled bool `json:"searchEnabled"` - AuditLogEnabled bool `json:"auditLogEnabled"` - AnalyticEnabled bool `json:"analyticEnabled"` - AnalyticEpoch *timeutil.Date `json:"analyticEpoch,omitempty"` - GitCommitHash string `json:"gitCommitHash,omitempty"` - GTMContainerID string `json:"gtmContainerID,omitempty"` - UIImplementation string `json:"uiImplementation,omitempty"` - UISettingsImplementation string `json:"uiSettingsImplementation,omitempty"` + AuthgearClientID string `json:"authgearClientID"` + AuthgearEndpoint string `json:"authgearEndpoint"` + AuthgearWebSDKSessionType string `json:"authgearWebSDKSessionType"` + SentryDSN string `json:"sentryDSN,omitempty"` + AppHostSuffix string `json:"appHostSuffix"` + AvailableLanguages []string `json:"availableLanguages"` + BuiltinLanguages []string `json:"builtinLanguages"` + Themes interface{} `json:"themes,omitempty"` + Translations interface{} `json:"translations,omitempty"` + SearchEnabled bool `json:"searchEnabled"` + AuditLogEnabled bool `json:"auditLogEnabled"` + AnalyticEnabled bool `json:"analyticEnabled"` + AnalyticEpoch *timeutil.Date `json:"analyticEpoch,omitempty"` + GitCommitHash string `json:"gitCommitHash,omitempty"` + GTMContainerID string `json:"gtmContainerID,omitempty"` + UIImplementation string `json:"uiImplementation,omitempty"` + UISettingsImplementation string `json:"uiSettingsImplementation,omitempty"` } diff --git a/pkg/portal/routes.go b/pkg/portal/routes.go index 07a0790265..3bb8d3fe10 100644 --- a/pkg/portal/routes.go +++ b/pkg/portal/routes.go @@ -31,7 +31,6 @@ func NewRouter(p *deps.RootProvider) http.Handler { p.Middleware(newPanicMiddleware), p.Middleware(newBodyLimitMiddleware), p.Middleware(newSentryMiddleware), - p.Middleware(newSessionInfoMiddleware), ) systemConfigJSONChain := httproute.Chain( rootChain, @@ -40,6 +39,7 @@ func NewRouter(p *deps.RootProvider) http.Handler { ) graphqlChain := httproute.Chain( rootChain, + p.Middleware(newSessionInfoMiddleware), securityMiddleware, httproute.MiddlewareFunc(httputil.NoStore), httputil.CheckContentType([]string{ @@ -49,6 +49,7 @@ func NewRouter(p *deps.RootProvider) http.Handler { ) adminAPIChain := httproute.Chain( rootChain, + p.Middleware(newSessionInfoMiddleware), // Middlewares that write headers are intentionally left out for this chain. // It is because the handler of this chain is a httputil.ReverseProxy. // We assume the proxied response has correct headers. diff --git a/pkg/portal/service/system_config.go b/pkg/portal/service/system_config.go index 536e294041..1f45b2dbf2 100644 --- a/pkg/portal/service/system_config.go +++ b/pkg/portal/service/system_config.go @@ -49,22 +49,23 @@ func (p *SystemConfigProvider) SystemConfig() (*model.SystemConfig, error) { } return &model.SystemConfig{ - AuthgearClientID: p.AuthgearConfig.ClientID, - AuthgearEndpoint: p.AuthgearConfig.Endpoint, - SentryDSN: p.FrontendSentryConfig.DSN, - AppHostSuffix: p.AppConfig.HostSuffix, - AvailableLanguages: intl.AvailableLanguages, - BuiltinLanguages: intl.BuiltinLanguages, - Themes: themes, - Translations: translations, - SearchEnabled: p.SearchConfig.Enabled, - AuditLogEnabled: p.AuditLogConfig.Enabled, - AnalyticEnabled: p.AnalyticConfig.Enabled, - AnalyticEpoch: analyticEpoch, - GitCommitHash: strings.TrimPrefix(version.Version, "git-"), - GTMContainerID: p.GTMConfig.ContainerID, - UIImplementation: string(p.GlobalUIImplementation), - UISettingsImplementation: string(p.GlobalUISettingsImplementation), + AuthgearClientID: p.AuthgearConfig.ClientID, + AuthgearEndpoint: p.AuthgearConfig.Endpoint, + AuthgearWebSDKSessionType: p.AuthgearConfig.WebSDKSessionType, + SentryDSN: p.FrontendSentryConfig.DSN, + AppHostSuffix: p.AppConfig.HostSuffix, + AvailableLanguages: intl.AvailableLanguages, + BuiltinLanguages: intl.BuiltinLanguages, + Themes: themes, + Translations: translations, + SearchEnabled: p.SearchConfig.Enabled, + AuditLogEnabled: p.AuditLogConfig.Enabled, + AnalyticEnabled: p.AnalyticConfig.Enabled, + AnalyticEpoch: analyticEpoch, + GitCommitHash: strings.TrimPrefix(version.Version, "git-"), + GTMContainerID: p.GTMConfig.ContainerID, + UIImplementation: string(p.GlobalUIImplementation), + UISettingsImplementation: string(p.GlobalUISettingsImplementation), }, nil } diff --git a/pkg/portal/session/deps.go b/pkg/portal/session/deps.go index 32cb11cdb1..3d3d83e3e6 100644 --- a/pkg/portal/session/deps.go +++ b/pkg/portal/session/deps.go @@ -1,10 +1,26 @@ package session import ( + "net/http" + "time" + "github.com/google/wire" + + "github.com/authgear/authgear-server/pkg/util/httputil" ) +type HTTPClient struct { + *http.Client +} + +func NewHTTPClient() HTTPClient { + return HTTPClient{ + httputil.NewExternalClient(5 * time.Second), + } +} + var DependencySet = wire.NewSet( + NewHTTPClient, wire.Struct(new(SessionInfoMiddleware), "*"), wire.Struct(new(SessionRequiredMiddleware), "*"), ) diff --git a/pkg/portal/session/middleware_session_info.go b/pkg/portal/session/middleware_session_info.go index a9aae17582..e8ddf58033 100644 --- a/pkg/portal/session/middleware_session_info.go +++ b/pkg/portal/session/middleware_session_info.go @@ -1,22 +1,177 @@ package session import ( + "context" + "fmt" "net/http" + "net/url" + "time" + + "github.com/lestrrat-go/jwx/v2/jwk" + "github.com/lestrrat-go/jwx/v2/jwt" + "github.com/patrickmn/go-cache" "github.com/authgear/authgear-server/pkg/api/model" + "github.com/authgear/authgear-server/pkg/lib/oauthrelyingparty/oauthrelyingpartyutil" + portalconfig "github.com/authgear/authgear-server/pkg/portal/config" + "github.com/authgear/authgear-server/pkg/util/clock" + "github.com/authgear/authgear-server/pkg/util/duration" ) -// nolint:golint -type SessionInfoMiddleware struct{} +var simpleCache = cache.New(5*time.Minute, 10*time.Minute) + +const cacheKeyOpenIDConfiguration = "openid-configuration" +const cacheKeyJWKs = "jwks" + +type jwtClock struct { + Clock clock.Clock +} + +func (c jwtClock) Now() time.Time { + return c.Clock.NowUTC() +} + +type SessionInfoMiddleware struct { + AuthgearConfig *portalconfig.AuthgearConfig + HTTPClient HTTPClient + Clock clock.Clock +} func (m *SessionInfoMiddleware) Handle(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - sessionInfo, err := model.NewSessionInfoFromHeaders(r.Header) - if err != nil { - panic(err) + switch m.AuthgearConfig.WebSDKSessionType { + case "refresh_token": + m.handleAuthorizationHeader(next, w, r) + case "cookie": + fallthrough + default: + m.handleCookie(next, w, r) } - - r = r.WithContext(WithSessionInfo(r.Context(), sessionInfo)) - next.ServeHTTP(w, r) }) } + +func (m *SessionInfoMiddleware) handleAuthorizationHeader(next http.Handler, w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + jwkSet, err := m.getJWKs(ctx) + if err != nil { + panic(err) + } + + var sessionInfo *model.SessionInfo + authorization := r.Header.Get("Authorization") + if authorization == "" { + // keep sessionInfo as nil. It means no session. + } else { + sessionInfo = m.jwtToSessionInfo(jwkSet, r.Header) + } + + r = r.WithContext(WithSessionInfo(ctx, sessionInfo)) + next.ServeHTTP(w, r) +} + +func (m *SessionInfoMiddleware) jwtToSessionInfo(jwkSet jwk.Set, header http.Header) (sessionInfo *model.SessionInfo) { + // Initialize to zero value. + // Zero value means invalid session. + sessionInfo = &model.SessionInfo{} + + token, err := jwt.ParseHeader(header, "Authorization", + jwt.WithVerify(true), + jwt.WithKeySet(jwkSet), + jwt.WithClock(jwtClock{m.Clock}), + jwt.WithAcceptableSkew(duration.ClockSkew), + ) + if err != nil { + return + } + + sessionInfo.UserID = token.Subject() + + anonymousIface, ok := token.Get(string(model.ClaimUserIsAnonymous)) + if !ok { + panic(fmt.Errorf("expected claim to be present: %v", model.ClaimUserIsAnonymous)) + } + sessionInfo.UserAnonymous = anonymousIface.(bool) + + isVerifiedIface, ok := token.Get(string(model.ClaimUserIsVerified)) + if !ok { + panic(fmt.Errorf("expected claim to be present: %v", model.ClaimUserIsVerified)) + } + sessionInfo.UserVerified = isVerifiedIface.(bool) + + canReauthenticate, ok := token.Get(string(model.ClaimUserCanReauthenticate)) + if !ok { + panic(fmt.Errorf("expected claim to be present: %v", model.ClaimUserCanReauthenticate)) + } + sessionInfo.UserCanReauthenticate = canReauthenticate.(bool) + + // auth_time is newly added to at+jwt, so it may not be present. + if authTimeIface, ok := token.Get(string(model.ClaimAuthTime)); ok { + switch v := authTimeIface.(type) { + case float64: + sessionInfo.AuthenticatedAt = time.Unix(int64(v), 0).UTC() + case int64: + sessionInfo.AuthenticatedAt = time.Unix(v, 0).UTC() + default: + panic(fmt.Errorf("unexpected type: %v %T", model.ClaimAuthTime, authTimeIface)) + } + } + + // amr is newly added to at+jwt, so it may not be present. + if amrIface, ok := token.Get(string(model.ClaimAMR)); ok { + amrSlice := amrIface.([]interface{}) + for _, amrValue := range amrSlice { + amrStr := amrValue.(string) + sessionInfo.SessionAMR = append(sessionInfo.SessionAMR, amrStr) + } + } + + rolesIface, ok := token.Get(string(model.ClaimAuthgearRoles)) + if !ok { + panic(fmt.Errorf("expected claim to be present: %v", model.ClaimAuthgearRoles)) + } + rolesSlice := rolesIface.([]interface{}) + for _, roleIface := range rolesSlice { + role := roleIface.(string) + sessionInfo.EffectiveRoles = append(sessionInfo.EffectiveRoles, role) + } + + sessionInfo.IsValid = true + return +} + +func (m *SessionInfoMiddleware) getJWKs(ctx context.Context) (jwk.Set, error) { + jwkIface, ok := simpleCache.Get(cacheKeyJWKs) + if ok { + return jwkIface.(jwk.Set), nil + } + + endpoint, err := url.JoinPath(m.AuthgearConfig.Endpoint, "/.well-known/openid-configuration") + if err != nil { + return nil, err + } + + oidcDiscoveryDocument, err := oauthrelyingpartyutil.FetchOIDCDiscoveryDocument(ctx, m.HTTPClient.Client, endpoint) + if err != nil { + return nil, err + } + simpleCache.Set(cacheKeyOpenIDConfiguration, oidcDiscoveryDocument, 0) + + jwkSet, err := oidcDiscoveryDocument.FetchJWKs(ctx, m.HTTPClient.Client) + if err != nil { + return nil, err + } + simpleCache.Set(cacheKeyJWKs, jwkSet, 0) + + return jwkSet, nil +} + +func (m *SessionInfoMiddleware) handleCookie(next http.Handler, w http.ResponseWriter, r *http.Request) { + sessionInfo, err := model.NewSessionInfoFromHeaders(r.Header) + if err != nil { + panic(err) + } + + r = r.WithContext(WithSessionInfo(r.Context(), sessionInfo)) + next.ServeHTTP(w, r) +} diff --git a/pkg/portal/wire.go b/pkg/portal/wire.go index f40002c4eb..31e57c31ea 100644 --- a/pkg/portal/wire.go +++ b/pkg/portal/wire.go @@ -40,6 +40,7 @@ func newSentryMiddleware(p *deps.RequestProvider) httproute.Middleware { func newSessionInfoMiddleware(p *deps.RequestProvider) httproute.Middleware { panic(wire.Build( + DependencySet, session.DependencySet, wire.Bind(new(httproute.Middleware), new(*session.SessionInfoMiddleware)), )) diff --git a/pkg/portal/wire_gen.go b/pkg/portal/wire_gen.go index 04613fe8ab..64b2833ae6 100644 --- a/pkg/portal/wire_gen.go +++ b/pkg/portal/wire_gen.go @@ -72,10 +72,22 @@ func newSentryMiddleware(p *deps.RequestProvider) httproute.Middleware { } func newSessionInfoMiddleware(p *deps.RequestProvider) httproute.Middleware { - sessionInfoMiddleware := &session.SessionInfoMiddleware{} + rootProvider := p.RootProvider + authgearConfig := rootProvider.AuthgearConfig + httpClient := session.NewHTTPClient() + clock := _wireSystemClockValue + sessionInfoMiddleware := &session.SessionInfoMiddleware{ + AuthgearConfig: authgearConfig, + HTTPClient: httpClient, + Clock: clock, + } return sessionInfoMiddleware } +var ( + _wireSystemClockValue = clock.NewSystemClock() +) + func newSessionRequiredMiddleware(p *deps.RequestProvider) httproute.Middleware { sessionRequiredMiddleware := &session.SessionRequiredMiddleware{} return sessionRequiredMiddleware @@ -96,9 +108,9 @@ func newGraphQLHandler(p *deps.RequestProvider) http.Handler { adminAPIConfig := rootProvider.AdminAPIConfig controller := rootProvider.ConfigSourceController configSource := deps.ProvideConfigSource(controller) - clock := _wireSystemClockValue + clockClock := _wireSystemClockValue adder := &authz.Adder{ - Clock: clock, + Clock: clockClock, } appHostSuffixes := environmentConfig.AppHostSuffixes appConfig := rootProvider.AppConfig @@ -122,7 +134,7 @@ func newGraphQLHandler(p *deps.RequestProvider) http.Handler { sqlBuilder := globaldb.NewSQLBuilder(globalDatabaseCredentialsEnvironmentConfig) sqlExecutor := globaldb.NewSQLExecutor(handle) domainService := &service.DomainService{ - Clock: clock, + Clock: clockClock, DomainConfig: configService, SQLBuilder: sqlBuilder, SQLExecutor: sqlExecutor, @@ -179,7 +191,7 @@ func newGraphQLHandler(p *deps.RequestProvider) http.Handler { Resolver: resolver, } collaboratorService := &service.CollaboratorService{ - Clock: clock, + Clock: clockClock, SQLBuilder: sqlBuilder, SQLExecutor: sqlExecutor, HTTPClient: httpClient, @@ -213,12 +225,12 @@ func newGraphQLHandler(p *deps.RequestProvider) http.Handler { AppBaseResources: appBaseResources, Tutorials: tutorialService, DenoClient: denoClientImpl, - Clock: clock, + Clock: clockClock, EnvironmentConfig: environmentConfig, DomainService: domainService, } store := &plan.Store{ - Clock: clock, + Clock: clockClock, SQLBuilder: sqlBuilder, SQLExecutor: sqlExecutor, } @@ -251,7 +263,7 @@ func newGraphQLHandler(p *deps.RequestProvider) http.Handler { Resources: manager, AppResMgrFactory: managerFactory, Plan: planService, - Clock: clock, + Clock: clockClock, AppSecretVisitTokenStore: appSecretVisitTokenStoreImpl, AppTesterTokenStore: testerStore, SAMLEnvironmentConfig: samlEnvironmentConfig, @@ -275,7 +287,7 @@ func newGraphQLHandler(p *deps.RequestProvider) http.Handler { chartService := &analytic.ChartService{ Database: readHandle, AuditStore: auditDBReadStore, - Clock: clock, + Clock: clockClock, AnalyticConfig: analyticConfig, } stripeConfig := rootProvider.StripeConfig @@ -288,7 +300,7 @@ func newGraphQLHandler(p *deps.RequestProvider) http.Handler { Plans: planService, GlobalRedisHandle: globalredisHandle, Cache: stripeCache, - Clock: clock, + Clock: clockClock, StripeConfig: stripeConfig, Endpoints: endpointsProvider, } @@ -303,7 +315,7 @@ func newGraphQLHandler(p *deps.RequestProvider) http.Handler { ConfigSourceStore: configsourceStore, PlanStore: store, UsageStore: globalDBStore, - Clock: clock, + Clock: clockClock, AppConfig: appConfig, } usageService := &service.UsageService{ @@ -324,7 +336,7 @@ func newGraphQLHandler(p *deps.RequestProvider) http.Handler { GlobalSQLExecutor: sqlExecutor, GlobalDatabase: handle, AuditDatabase: writeHandle, - Clock: clock, + Clock: clockClock, LoggerFactory: logFactory, } onboardService := &service.OnboardService{ @@ -364,7 +376,6 @@ func newGraphQLHandler(p *deps.RequestProvider) http.Handler { } var ( - _wireSystemClockValue = clock.NewSystemClock() _wireDefaultLanguageTagValue = template.DefaultLanguageTag(intl.BuiltinBaseLanguage) _wireSupportedLanguageTagsValue = template.SupportedLanguageTags([]string{intl.BuiltinBaseLanguage}) ) diff --git a/portal/package-lock.json b/portal/package-lock.json index 967c1edb99..d37dd78dbd 100644 --- a/portal/package-lock.json +++ b/portal/package-lock.json @@ -11,7 +11,7 @@ ], "dependencies": { "@apollo/client": "3.8.7", - "@authgear/web": "1.0.1", + "@authgear/web": "^2.12.0", "@elgorditosalsero/react-gtm-hook": "2.7.2", "@fluentui/font-icons-mdl2": "^8.5.55", "@fluentui/merge-styles": "^8.6.13", @@ -281,9 +281,9 @@ } }, "node_modules/@authgear/web": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@authgear/web/-/web-1.0.1.tgz", - "integrity": "sha512-FgRVqyIviqRKWpJgJpKqLFSVkYN2R/CIXudGsP87yMFOKg8UPzPDnAUOs7D9hlXc/8anvysyglhjul3TwD0klg==" + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@authgear/web/-/web-2.12.0.tgz", + "integrity": "sha512-OTqWh9rEbkgBfR342HZYQkOfj+DbkrMw36w5JAT1aZl1FU2xJBryPOZvqnZtQQYqP7kR/lgI3ZnTV6ySNcz+Bg==" }, "node_modules/@babel/code-frame": { "version": "7.25.7", @@ -17216,9 +17216,9 @@ } }, "@authgear/web": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@authgear/web/-/web-1.0.1.tgz", - "integrity": "sha512-FgRVqyIviqRKWpJgJpKqLFSVkYN2R/CIXudGsP87yMFOKg8UPzPDnAUOs7D9hlXc/8anvysyglhjul3TwD0klg==" + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@authgear/web/-/web-2.12.0.tgz", + "integrity": "sha512-OTqWh9rEbkgBfR342HZYQkOfj+DbkrMw36w5JAT1aZl1FU2xJBryPOZvqnZtQQYqP7kR/lgI3ZnTV6ySNcz+Bg==" }, "@babel/code-frame": { "version": "7.25.7", diff --git a/portal/package.json b/portal/package.json index bf53d3871b..fd351fe8f0 100644 --- a/portal/package.json +++ b/portal/package.json @@ -59,7 +59,7 @@ }, "dependencies": { "@apollo/client": "3.8.7", - "@authgear/web": "1.0.1", + "@authgear/web": "^2.12.0", "@elgorditosalsero/react-gtm-hook": "2.7.2", "@fluentui/font-icons-mdl2": "^8.5.55", "@fluentui/merge-styles": "^8.6.13", diff --git a/portal/src/OAuthRedirect.tsx b/portal/src/OAuthRedirect.tsx index 9bc1425396..baa590eb3e 100644 --- a/portal/src/OAuthRedirect.tsx +++ b/portal/src/OAuthRedirect.tsx @@ -1,6 +1,6 @@ import React, { useEffect } from "react"; -import authgear from "@authgear/web"; import { useNavigate } from "react-router-dom"; +import { useFinishAuthentication } from "./graphql/portal/Authenticated"; function decodeOAuthState(oauthState: string): Record { // eslint-disable-next-line @typescript-eslint/no-deprecated @@ -13,10 +13,10 @@ function isString(value: unknown): value is string { const OAuthRedirect: React.VFC = function OAuthRedirect() { const navigate = useNavigate(); + const finishAuthentication = useFinishAuthentication(); useEffect(() => { - authgear - .finishAuthentication() + finishAuthentication() .then((result) => { const state = result.state ? decodeOAuthState(result.state) : null; let navigateToPath = "/"; @@ -37,7 +37,7 @@ const OAuthRedirect: React.VFC = function OAuthRedirect() { .catch((err) => { console.error(err); }); - }, [navigate]); + }, [navigate, finishAuthentication]); return null; }; diff --git a/portal/src/ReactApp.tsx b/portal/src/ReactApp.tsx index 4d816fdb0c..489325b8a2 100644 --- a/portal/src/ReactApp.tsx +++ b/portal/src/ReactApp.tsx @@ -21,7 +21,6 @@ import { Context, } from "@oursky/react-messageformat"; import { ApolloProvider } from "@apollo/client"; -import authgear from "@authgear/web"; import { Helmet, HelmetProvider } from "react-helmet-async"; import AppRoot from "./AppRoot"; import MESSAGES from "./locale-data/en.json"; @@ -37,7 +36,10 @@ import { import { loadTheme, ILinkProps } from "@fluentui/react"; import ExternalLink from "./ExternalLink"; import Link from "./Link"; -import Authenticated from "./graphql/portal/Authenticated"; +import Authenticated, { + configureAuthgear, + AuthenticatedContextProvider, +} from "./graphql/portal/Authenticated"; import InternalRedirect from "./InternalRedirect"; import { LoadingContextProvider } from "./hook/loading"; import { ErrorContextProvider } from "./hook/error"; @@ -116,11 +118,6 @@ async function initApp(systemConfig: SystemConfig) { } loadTheme(systemConfig.themes.main); - await authgear.configure({ - sessionType: "cookie", - clientID: systemConfig.authgearClientID, - endpoint: systemConfig.authgearEndpoint, - }); } // ReactAppRoutes defines the routes. @@ -340,6 +337,11 @@ const ReactApp: React.VFC = function ReactApp() { loadSystemConfig() .then(async (cfg) => { await initApp(cfg); + await configureAuthgear({ + clientID: cfg.authgearClientID, + endpoint: cfg.authgearEndpoint, + sessionType: cfg.authgearWebSDKSessionType, + }); setSystemConfig(cfg); }) .catch((err) => { @@ -379,17 +381,19 @@ const ReactApp: React.VFC = function ReactApp() { - - - - - - + + + + + + + + diff --git a/portal/src/ScreenHeader.tsx b/portal/src/ScreenHeader.tsx index d7a203b2f8..eb836a99a0 100644 --- a/portal/src/ScreenHeader.tsx +++ b/portal/src/ScreenHeader.tsx @@ -1,7 +1,6 @@ import React, { useCallback, useContext, useMemo } from "react"; import { useParams } from "react-router-dom"; import { Context } from "@oursky/react-messageformat"; -import authgear from "@authgear/web"; import { Icon, Text, @@ -21,7 +20,8 @@ import styles from "./ScreenHeader.module.css"; import { useSystemConfig } from "./context/SystemConfigContext"; import { useBoolean } from "@fluentui/react-hooks"; import ExternalLink from "./ExternalLink"; -import { useCapture, useReset } from "./gtm_v2"; +import { useLogout } from "./graphql/portal/Authenticated"; +import { useCapture } from "./gtm_v2"; interface LogoProps { isNavbarHeader?: boolean; @@ -182,22 +182,14 @@ const ScreenHeader: React.VFC = function ScreenHeader(props) { const { viewer } = useViewerQuery(); const [isNavbarOpen, { setTrue: openNavbar, setFalse: dismissNavbar }] = useBoolean(false); - const reset = useReset(); - const redirectURI = window.location.origin + "/"; + const logout = useLogout(); const onClickLogout = useCallback(() => { - authgear - .logout({ - redirectURI, - }) - .then(() => { - reset(); - }) - .catch((err) => { - console.error(err); - }); - }, [redirectURI, reset]); + logout().catch((err: unknown) => { + console.error(err); + }); + }, [logout]); const onClickCookiePreference = useCallback(() => { if (window.Osano?.cm !== undefined) { diff --git a/portal/src/graphql/adminapi/EditPictureScreen.tsx b/portal/src/graphql/adminapi/EditPictureScreen.tsx index 05c42fd27e..c762c3b745 100644 --- a/portal/src/graphql/adminapi/EditPictureScreen.tsx +++ b/portal/src/graphql/adminapi/EditPictureScreen.tsx @@ -10,7 +10,8 @@ import React, { import { FormattedMessage, Context } from "@oursky/react-messageformat"; import { Dialog, DialogFooter, Spinner, SpinnerSize } from "@fluentui/react"; import { useParams, useNavigate } from "react-router-dom"; -import axios, { AxiosProgressEvent } from "axios"; +import axios, { AxiosProgressEvent, RawAxiosRequestHeaders } from "axios"; +import authgear from "@authgear/web"; import PrimaryButton from "../../PrimaryButton"; import DefaultButton from "../../DefaultButton"; import { FormProvider } from "../../form"; @@ -258,8 +259,16 @@ function EditPictureScreenContent(props: EditPictureScreenContentProps) { percentComplete: undefined, }); + await authgear.refreshAccessTokenIfNeeded(); + + const headers: RawAxiosRequestHeaders = {}; + if (authgear.accessToken != null) { + headers.Authorization = `Bearer ${authgear.accessToken}`; + } + const resp = await axios(`/api/apps/${appID}/_api/admin/images/upload`, { method: "GET", + headers, onUploadProgress: onProgress, onDownloadProgress: onProgress, }); @@ -269,6 +278,7 @@ function EditPictureScreenContent(props: EditPictureScreenContentProps) { formData.append("file", blob); const uploadResp = await axios(upload_url, { method: "POST", + headers, data: formData, onUploadProgress: onProgress, onDownloadProgress: onProgress, diff --git a/portal/src/graphql/adminapi/apollo.ts b/portal/src/graphql/adminapi/apollo.ts index 63eecee394..6a89525314 100644 --- a/portal/src/graphql/adminapi/apollo.ts +++ b/portal/src/graphql/adminapi/apollo.ts @@ -1,4 +1,5 @@ import { ApolloClient, HttpLink, InMemoryCache } from "@apollo/client"; +import authgear from "@authgear/web"; import { createLogoutLink } from "../portal/apollo"; export function makeGraphQLEndpoint(graphqlOpaqueAppID: string): string { @@ -11,6 +12,7 @@ export function makeClient( ): ApolloClient { const httpLink = new HttpLink({ uri: makeGraphQLEndpoint(graphqlOpaqueAppID), + fetch: authgear.fetch.bind(authgear), }); const logoutLink = createLogoutLink(() => { onLogout(); diff --git a/portal/src/graphql/portal/AcceptAdminInvitationScreen.tsx b/portal/src/graphql/portal/AcceptAdminInvitationScreen.tsx index f60b3d0cdd..6528fd797b 100644 --- a/portal/src/graphql/portal/AcceptAdminInvitationScreen.tsx +++ b/portal/src/graphql/portal/AcceptAdminInvitationScreen.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useContext, useMemo } from "react"; -import authgear from "@authgear/web"; +import authgear, { PromptOption } from "@authgear/web"; import { Text, DefaultEffects } from "@fluentui/react"; import { Context, @@ -175,7 +175,7 @@ const AcceptAdminInvitationScreen: React.VFC = authgear .startAuthentication({ redirectURI, - prompt: "login", + prompt: PromptOption.Login, state: encodeOAuthState({ originalPath, }), diff --git a/portal/src/graphql/portal/Authenticated.tsx b/portal/src/graphql/portal/Authenticated.tsx index aa2adf6b00..3de421c5d1 100644 --- a/portal/src/graphql/portal/Authenticated.tsx +++ b/portal/src/graphql/portal/Authenticated.tsx @@ -1,10 +1,41 @@ -import React, { useEffect } from "react"; -import authgear from "@authgear/web"; +import React, { + useEffect, + useCallback, + useMemo, + useState, + useContext, + createContext, +} from "react"; +import authgear, { + PromptOption, + WebContainer, + SessionStateChangeReason, + SessionState, + AuthenticateResult, + ConfigureOptions, +} from "@authgear/web"; import { useNavigate } from "react-router-dom"; import ShowError from "../../ShowError"; import ShowLoading from "../../ShowLoading"; import { useViewerQuery } from "./query/viewerQuery"; import { InternalRedirectState } from "../../InternalRedirect"; +import { useReset } from "../../gtm_v2"; + +interface AuthenticatedContextValue { + loading: boolean; + error: unknown; + authenticated: boolean; + refetch: () => Promise; +} + +const DEFAULT_VALUE: AuthenticatedContextValue = { + loading: true, + error: null, + authenticated: false, + refetch: async () => {}, +}; + +const AuthenticatedContext = createContext(DEFAULT_VALUE); interface ShowQueryResultProps { isAuthenticated: boolean; @@ -31,7 +62,7 @@ const ShowQueryResult: React.VFC = authgear .startAuthentication({ redirectURI, - prompt: "login", + prompt: PromptOption.Login, state: encodeOAuthState({ originalPath, }), @@ -57,7 +88,8 @@ interface Props { const Authenticated: React.VFC = function Authenticated( ownProps: Props ) { - const { loading, error, viewer, refetch } = useViewerQuery(); + const { loading, error, authenticated, refetch } = + useContext(AuthenticatedContext); if (loading) { return ; @@ -67,7 +99,7 @@ const Authenticated: React.VFC = function Authenticated( return ; } - return ; + return ; }; // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters @@ -101,4 +133,118 @@ export async function startReauthentication( }); } +export function useLogout(): () => Promise { + const redirectURI = window.location.origin + "/"; + const reset = useReset(); + const logout = useCallback(async () => { + await authgear.logout({ + redirectURI, + }); + reset(); + }, [redirectURI, reset]); + return logout; +} + +// useFinishAuthentication was introduced to avoid a possible race condition. +// The ultimate source of truth to determine whether the user has authenticated or not is by checking viewer != null. +// Therefore, when we finish authentication, we always refetch the query. +// +// Not doing this will result in a situation where +// 1. authgear.accessToken != null (In the end-user's point of view, he just authenticated, and being redirected back to the portal) +// 2. { loading = false, viewer = null } (because refetch() DOES NOT set loading to true immediately) +// 3. Then Authenticated will redirect the end-user to authenticate again, which is buggy. +export function useFinishAuthentication(): () => Promise { + const { refetch } = useContext(AuthenticatedContext); + const finishAuthentication = useCallback(async () => { + const result = await authgear.finishAuthentication(); + await refetch(); + return result; + }, [refetch]); + return finishAuthentication; +} + +export interface ConfigureAuthgearOptions { + clientID: string; + endpoint: string; + sessionType: NonNullable; +} + +export async function configureAuthgear( + options: ConfigureAuthgearOptions +): Promise { + // eslint-disable-next-line no-console -- Output the session type to console for easier debugging. + console.info("authgear: sessionType = %s", options.sessionType); + await authgear.configure({ + sessionType: options.sessionType, + clientID: options.clientID, + endpoint: options.endpoint, + }); +} + +export interface AuthenticatedContextProviderProps { + children?: React.ReactElement; +} + +export function AuthenticatedContextProvider( + props: AuthenticatedContextProviderProps +): React.ReactElement | null { + const [sessionState, setSessionState] = useState(authgear.sessionState); + const { viewer, loading, error, refetch } = useViewerQuery(); + + const delegate = useMemo(() => { + return { + onSessionStateChange: ( + container: WebContainer, + _reason: SessionStateChangeReason + ) => { + setSessionState(container.sessionState); + refetch(); + }, + }; + }, [refetch]); + + // Set delegate + useEffect(() => { + authgear.delegate = delegate; + }, [delegate]); + + const value = useMemo(() => { + let authenticated = false; + switch (authgear.sessionType) { + case "cookie": + // FIXME(authgear-sdk): Update to the version that includes https://github.com/authgear/authgear-sdk-js/pull/336 + // When switching from refresh_token to cookie, with the fix in https://github.com/authgear/authgear-sdk-js/pull/336, + // authgear SDK will not load refresh token, then thus authgear.fetch will not include + // Authorization header. + // + // We just need to check if we can actually fetch viewer. + authenticated = viewer != null; + break; + case "refresh_token": + // When switching from cookie to refresh_token, the cookie may left in the browser. + // So we have to check if authgear SDK does have a stored refresh_token. + // This checking is reflected by authgear.sessionState. + authenticated = + sessionState === SessionState.Authenticated && viewer != null; + break; + + // So now, switching between cookie and refresh_token is possible and work seamlessly. + // Of course, the switch implies the end-user has to authenticate again. + } + + return { + loading, + error, + authenticated, + refetch, + }; + }, [sessionState, loading, error, viewer, refetch]); + + return ( + + {props.children} + + ); +} + export default Authenticated; diff --git a/portal/src/graphql/portal/apollo.ts b/portal/src/graphql/portal/apollo.ts index f431e62e92..b4d71578c5 100644 --- a/portal/src/graphql/portal/apollo.ts +++ b/portal/src/graphql/portal/apollo.ts @@ -1,4 +1,5 @@ import { createContext, useContext } from "react"; +import authgear from "@authgear/web"; import { ApolloCache, ApolloClient, @@ -77,7 +78,10 @@ export function createClient(options: { onLogout: () => void; }): ApolloClient { const { cache } = options; - const httpLink = new HttpLink({ uri: "/api/graphql" }); + const httpLink = new HttpLink({ + uri: "/api/graphql", + fetch: authgear.fetch.bind(authgear), + }); return new ApolloClient({ link: createLogoutLink(options.onLogout).concat(httpLink), diff --git a/portal/src/system-config.ts b/portal/src/system-config.ts index c1583df37c..0dee2c0f8a 100644 --- a/portal/src/system-config.ts +++ b/portal/src/system-config.ts @@ -5,6 +5,7 @@ import { DEFAULT_TEMPLATE_LOCALE } from "./resources"; export interface SystemConfig { authgearClientID: string; authgearEndpoint: string; + authgearWebSDKSessionType: "cookie" | "refresh_token"; sentryDSN: string; appHostSuffix: string; availableLanguages: string[]; @@ -246,6 +247,7 @@ export function instantiateSystemConfig( return { authgearClientID: config.authgearClientID ?? "", authgearEndpoint: config.authgearEndpoint ?? "", + authgearWebSDKSessionType: config.authgearWebSDKSessionType ?? "cookie", sentryDSN: config.sentryDSN ?? "", appHostSuffix: config.appHostSuffix ?? "", availableLanguages: config.availableLanguages ?? [DEFAULT_TEMPLATE_LOCALE],