From 5f4841a964957f7da6d5e51cb8d92dcaa0b76e44 Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Tue, 4 Aug 2026 15:11:36 +0530 Subject: [PATCH 1/2] fix(client): persist cookies so the MFA session survives calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each call built its own http.Client with no jar, so the session cookie the server sets on signup/login was dropped before the next request. Since server 2.4.0 MFA is on by default: signup/login withhold the access token, return "Proceed to mfa setup", and identify the pending user by that cookie. SkipMfaSetup and VerifyOtp resolve it only if the cookie comes back, so both failed with "invalid session" — the whole MFA surface was unreachable from Go while the methods existed and read as correct. Share one cookie-aware client on AuthorizerClient, exposed via HTTPClient() so a zero-value struct lazily gains a jar too. --- client.go | 34 +++++++++++++++++++++++ cookie_jar_test.go | 69 ++++++++++++++++++++++++++++++++++++++++++++++ get_token.go | 3 +- graphql.go | 3 +- revoke_token.go | 2 +- 5 files changed, 106 insertions(+), 5 deletions(-) create mode 100644 cookie_jar_test.go diff --git a/client.go b/client.go index 00f1f32..6ecf3e3 100644 --- a/client.go +++ b/client.go @@ -4,7 +4,10 @@ package authorizer import ( "fmt" + "net/http" + "net/http/cookiejar" "strings" + "time" ) // AuthorizerClient defines the attributes required to initiate authorizer client @@ -19,6 +22,36 @@ type AuthorizerClient struct { // GRPCEndpoint overrides the host:port dialed when Protocol is grpc. When // empty it is derived from AuthorizerURL using the gRPC default port. GRPCEndpoint string + + // httpClient is shared across every call on this client and carries a + // cookie jar. The jar is REQUIRED, not an optimisation: the MFA offer flow + // identifies the pending user by a session cookie the server sets on + // signup/login, and SkipMfaSetup / VerifyOtp only resolve it if that cookie + // is sent back. Building a fresh http.Client per call, as this SDK used to, + // dropped the cookie and made those calls fail with "invalid session" — + // i.e. the whole MFA surface was unreachable from Go. + httpClient *http.Client +} + +// newHTTPClient builds the shared cookie-aware client. cookiejar.New with a nil +// options value never returns an error, but the error is handled rather than +// ignored so a future options change cannot silently produce a jar-less client. +func newHTTPClient() *http.Client { + jar, err := cookiejar.New(nil) + if err != nil { + return &http.Client{Timeout: 30 * time.Second} + } + return &http.Client{Timeout: 30 * time.Second, Jar: jar} +} + +// HTTPClient returns the shared cookie-aware http client, initialising it on +// first use so a zero-value AuthorizerClient (or one built by an older +// constructor path) still carries a jar. +func (c *AuthorizerClient) HTTPClient() *http.Client { + if c.httpClient == nil { + c.httpClient = newHTTPClient() + } + return c.httpClient } // ClientOption customizes an AuthorizerClient at construction time. @@ -76,6 +109,7 @@ func NewAuthorizerClient(clientID, authorizerURL, redirectURL string, extraHeade ClientID: clientID, ExtraHeaders: headers, Protocol: ProtocolGraphQL, + httpClient: newHTTPClient(), } for _, opt := range opts { opt(c) diff --git a/cookie_jar_test.go b/cookie_jar_test.go new file mode 100644 index 0000000..00da5ec --- /dev/null +++ b/cookie_jar_test.go @@ -0,0 +1,69 @@ +package authorizer + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +// TestClientPersistsCookiesAcrossCalls is the regression test for the MFA +// offer flow. +// +// Since server 2.4.0, MFA is on by default: signup/login withhold the access +// token and return "Proceed to mfa setup", identifying the pending user by a +// session cookie. SkipMfaSetup and VerifyOtp resolve that user ONLY if the +// cookie is sent back. +// +// This SDK previously built a fresh http.Client per call, so the cookie was +// dropped between them and every one of those calls failed with "invalid +// session" — the entire MFA surface was unreachable from Go, while the methods +// existed and looked correct. +func TestClientPersistsCookiesAcrossCalls(t *testing.T) { + var secondRequestCookie string + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if c, err := r.Cookie("mfa_session"); err == nil { + secondRequestCookie = c.Value + } + http.SetCookie(w, &http.Cookie{Name: "mfa_session", Value: "session-abc", Path: "/"}) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":{"ok":true}}`)) + })) + defer srv.Close() + + c, err := NewAuthorizerClient("test-client", srv.URL, "", nil) + if err != nil { + t.Fatalf("new client: %v", err) + } + + // First call receives the cookie. + if _, err := c.ExecuteGraphQL(&GraphQLRequest{Query: "{ __typename }"}, nil); err != nil { + t.Fatalf("first call: %v", err) + } + // Second call must send it back. + if _, err := c.ExecuteGraphQL(&GraphQLRequest{Query: "{ __typename }"}, nil); err != nil { + t.Fatalf("second call: %v", err) + } + + if secondRequestCookie != "session-abc" { + t.Fatalf("cookie not replayed on the second call (got %q) — "+ + "SkipMfaSetup/VerifyOtp will fail with \"invalid session\"", secondRequestCookie) + } +} + +// TestHTTPClientAlwaysHasAJar pins that no construction path yields a +// jar-less client, including a zero-value struct built without the constructor. +func TestHTTPClientAlwaysHasAJar(t *testing.T) { + c, err := NewAuthorizerClient("test-client", "http://localhost:8080", "", nil) + if err != nil { + t.Fatalf("new client: %v", err) + } + if c.HTTPClient().Jar == nil { + t.Fatal("constructor produced a client with no cookie jar") + } + + var zero AuthorizerClient + if zero.HTTPClient().Jar == nil { + t.Fatal("zero-value client must lazily gain a cookie jar") + } +} diff --git a/get_token.go b/get_token.go index 23919d4..cd8d326 100644 --- a/get_token.go +++ b/get_token.go @@ -8,7 +8,6 @@ import ( "net/http" "net/url" "strings" - "time" ) // GetTokenRequest defines attributes for token request. Only the set (non-nil) @@ -116,7 +115,7 @@ func (c *AuthorizerClient) GetToken(req *GetTokenRequest) (*TokenResponse, error } } - client := http.Client{Timeout: 30 * time.Second} + client := c.HTTPClient() res, err := client.Do(httpReq) if err != nil { return nil, err diff --git a/graphql.go b/graphql.go index f329d27..a14fc2b 100644 --- a/graphql.go +++ b/graphql.go @@ -7,7 +7,6 @@ import ( "io" "net/http" "net/url" - "time" ) // GraphQLRequest is object used to make graphql queries @@ -32,7 +31,7 @@ func (c *AuthorizerClient) ExecuteGraphQL(req *GraphQLRequest, headers map[strin return nil, err } - client := http.Client{Timeout: 30 * time.Second} + client := c.HTTPClient() httpReq, err := http.NewRequest(http.MethodPost, c.AuthorizerURL+"/graphql", bytes.NewReader(jsonReq)) if err != nil { return nil, err diff --git a/revoke_token.go b/revoke_token.go index a33da74..4f2a89b 100644 --- a/revoke_token.go +++ b/revoke_token.go @@ -29,7 +29,7 @@ func (c *AuthorizerClient) RevokeToken(req *RevokeTokenInput) (*Response, error) return nil, err } - client := http.Client{} + client := c.HTTPClient() httpReq, err := http.NewRequest(http.MethodPost, c.AuthorizerURL+"/oauth/revoke", bytes.NewReader(jsonReq)) if err != nil { return nil, err From 6793d6998a12ae74db8f21808fa4264c02d72c04 Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Tue, 4 Aug 2026 15:43:40 +0530 Subject: [PATCH 2/2] fix(client): carry the session cookie over rest and grpc too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cookie jar landed on the GraphQL transport only. REST kept using http.DefaultClient (jar-less) and gRPC had no cookie handling at all, so the MFA session set by signup/login was dropped on both and SkipMfaSetup answered "invalid session" — the MFA surface stayed unreachable over two of three protocols. REST now uses the client's shared http.Client. gRPC gets a unary interceptor that replays jar cookies as `cookie` metadata and stores the server's `set-cookie` header metadata back into the jar, which is the cookie bridge the server's grpc transport already expects. test: resolve the default MFA offer in the integration suite Since server 2.4.0 MFA is on by default: signup/login withhold the access token and offer enrollment. The suite assumed the old behaviour and had 16 failures across graphql/rest/grpc. Shared signUp/login helpers decline the offer via SkipMfaSetup and return the withheld token; a response that is not an MFA offer passes through untouched, so a genuinely missing token still fails. --- admin_client.go | 4 +- client.go | 15 ++++ cookie_jar_test.go | 93 ++++++++++++++++++++ execute.go | 2 +- protocol.go | 35 +++++++- test/authorizer_test.go | 187 ++++++++++++++++------------------------ test/protocol_test.go | 31 +------ transport.go | 13 ++- 8 files changed, 234 insertions(+), 146 deletions(-) diff --git a/admin_client.go b/admin_client.go index 9faf30d..f5baa92 100644 --- a/admin_client.go +++ b/admin_client.go @@ -151,12 +151,12 @@ func (c *AuthorizerAdminClient) execute(spec adminMethodSpec, out interface{}) e } if spec.restResponse != nil { msg := spec.restResponse() - if err := doREST(c.AuthorizerURL, spec.restMethod, spec.restPath, spec.restBody, c.ExtraHeaders, map[string]string{adminSecretHeader: c.AdminSecret}, msg); err != nil { + if err := doREST(nil, c.AuthorizerURL, spec.restMethod, spec.restPath, spec.restBody, c.ExtraHeaders, map[string]string{adminSecretHeader: c.AdminSecret}, msg); err != nil { return err } return unwrapProto(msg, spec.responseUnwrap, out) } - return doREST(c.AuthorizerURL, spec.restMethod, spec.restPath, spec.restBody, c.ExtraHeaders, map[string]string{adminSecretHeader: c.AdminSecret}, out) + return doREST(nil, c.AuthorizerURL, spec.restMethod, spec.restPath, spec.restBody, c.ExtraHeaders, map[string]string{adminSecretHeader: c.AdminSecret}, out) case ProtocolGRPC: if spec.grpcCall == nil { diff --git a/client.go b/client.go index 6ecf3e3..41afad8 100644 --- a/client.go +++ b/client.go @@ -6,8 +6,11 @@ import ( "fmt" "net/http" "net/http/cookiejar" + "net/url" "strings" "time" + + "google.golang.org/grpc" ) // AuthorizerClient defines the attributes required to initiate authorizer client @@ -54,6 +57,18 @@ func (c *AuthorizerClient) HTTPClient() *http.Client { return c.httpClient } +// dialGRPC opens a gRPC connection whose calls share this client's cookie jar, +// so a session established over gRPC (or over HTTP) survives the next call. +func (c *AuthorizerClient) dialGRPC() (*grpc.ClientConn, error) { + jar := c.HTTPClient().Jar + u, err := url.Parse(c.AuthorizerURL) + if jar == nil || err != nil || u.Host == "" { + return grpcDial(c.AuthorizerURL, c.GRPCEndpoint) + } + return grpcDial(c.AuthorizerURL, c.GRPCEndpoint, + grpc.WithChainUnaryInterceptor(cookieInterceptor(jar, u))) +} + // ClientOption customizes an AuthorizerClient at construction time. type ClientOption func(*AuthorizerClient) diff --git a/cookie_jar_test.go b/cookie_jar_test.go index 00da5ec..453ebc1 100644 --- a/cookie_jar_test.go +++ b/cookie_jar_test.go @@ -1,9 +1,16 @@ package authorizer import ( + "context" "net/http" + "net/http/cookiejar" "net/http/httptest" + "net/url" + "strings" "testing" + + "google.golang.org/grpc" + "google.golang.org/grpc/metadata" ) // TestClientPersistsCookiesAcrossCalls is the regression test for the MFA @@ -51,6 +58,92 @@ func TestClientPersistsCookiesAcrossCalls(t *testing.T) { } } +// TestRESTTransportPersistsCookies pins the same contract for the REST +// transport, which kept using http.DefaultClient — jar-less — after the jar was +// added, so MFA over rest failed with "invalid session" while graphql worked. +func TestRESTTransportPersistsCookies(t *testing.T) { + var secondRequestCookie string + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if c, err := r.Cookie("mfa_session"); err == nil { + secondRequestCookie = c.Value + } + http.SetCookie(w, &http.Cookie{Name: "mfa_session", Value: "session-abc", Path: "/"}) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{}`)) + })) + defer srv.Close() + + c, err := NewAuthorizerClient("test-client", srv.URL, "", nil, WithProtocol(ProtocolREST)) + if err != nil { + t.Fatalf("new client: %v", err) + } + for i := 0; i < 2; i++ { + if err := c.executeREST(http.MethodPost, "/v1/signup", map[string]string{}, nil, nil); err != nil { + t.Fatalf("rest call %d: %v", i, err) + } + } + + if secondRequestCookie != "session-abc" { + t.Fatalf("cookie not replayed on the second REST call (got %q)", secondRequestCookie) + } +} + +// TestGRPCCookieInterceptor pins the gRPC half of the same contract: the +// server hands cookies out as `set-cookie` header metadata and expects them +// back as a `cookie` entry, so without this the MFA flow is unreachable over +// gRPC. The invoker is stubbed — the contract under test is metadata handling, +// not the wire. +func TestGRPCCookieInterceptor(t *testing.T) { + u, _ := url.Parse("http://authorizer.test") + jar, err := cookiejar.New(nil) + if err != nil { + t.Fatalf("jar: %v", err) + } + intercept := cookieInterceptor(jar, u) + + // First call: the server sets a cookie, the client sends none. + var sentFirst []string + err = intercept(context.Background(), "/Signup", nil, nil, nil, + func(ctx context.Context, method string, req, reply interface{}, cc *grpc.ClientConn, opts ...grpc.CallOption) error { + md, _ := metadata.FromOutgoingContext(ctx) + sentFirst = md.Get("cookie") + setHeader(opts, metadata.Pairs("set-cookie", "mfa_session=session-abc; Path=/")) + return nil + }) + if err != nil { + t.Fatalf("first call: %v", err) + } + if len(sentFirst) != 0 { + t.Errorf("expected no cookie metadata on the first call, got %v", sentFirst) + } + + // Second call: the stored cookie must be replayed. + var sentSecond []string + err = intercept(context.Background(), "/SkipMfaSetup", nil, nil, nil, + func(ctx context.Context, method string, req, reply interface{}, cc *grpc.ClientConn, opts ...grpc.CallOption) error { + md, _ := metadata.FromOutgoingContext(ctx) + sentSecond = md.Get("cookie") + return nil + }) + if err != nil { + t.Fatalf("second call: %v", err) + } + if len(sentSecond) != 1 || !strings.Contains(sentSecond[0], "mfa_session=session-abc") { + t.Fatalf("cookie not replayed over grpc (got %v) — SkipMfaSetup will fail with \"invalid session\"", sentSecond) + } +} + +// setHeader writes response header metadata the way a real grpc call does, by +// filling the grpc.Header(&md) call option the interceptor appended. +func setHeader(opts []grpc.CallOption, md metadata.MD) { + for _, o := range opts { + if h, ok := o.(grpc.HeaderCallOption); ok { + *h.HeaderAddr = md + } + } +} + // TestHTTPClientAlwaysHasAJar pins that no construction path yields a // jar-less client, including a zero-value struct built without the constructor. func TestHTTPClientAlwaysHasAJar(t *testing.T) { diff --git a/execute.go b/execute.go index 47d12c2..b58bcea 100644 --- a/execute.go +++ b/execute.go @@ -91,7 +91,7 @@ func (c *AuthorizerClient) execute(spec methodSpec, headers map[string]string, o if spec.grpcCall == nil { return unsupportedProtocol(spec.name, c.Protocol, spec.supported()) } - conn, err := grpcDial(c.AuthorizerURL, c.GRPCEndpoint) + conn, err := c.dialGRPC() if err != nil { return err } diff --git a/protocol.go b/protocol.go index c0caf01..9b5d894 100644 --- a/protocol.go +++ b/protocol.go @@ -4,12 +4,14 @@ import ( "context" "crypto/tls" "fmt" + "net/http" "net/url" "strings" "google.golang.org/grpc" "google.golang.org/grpc/credentials" "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/metadata" ) // Protocol selects the wire transport a client uses to talk to authorizer. @@ -40,7 +42,7 @@ const defaultGRPCPort = "9091" // not the HTTP URL's port. An https:// URL (or an explicit :443 host) uses TLS; // everything else dials insecurely, matching the typical self-hosted // http://host:8080 deployment. -func grpcDial(authorizerURL, grpcEndpoint string) (*grpc.ClientConn, error) { +func grpcDial(authorizerURL, grpcEndpoint string, opts ...grpc.DialOption) (*grpc.ClientConn, error) { u, err := url.Parse(authorizerURL) if err != nil { return nil, fmt.Errorf("invalid authorizerURL %q: %w", authorizerURL, err) @@ -67,7 +69,36 @@ func grpcDial(authorizerURL, grpcEndpoint string) (*grpc.ClientConn, error) { host += ":443" } - return grpc.NewClient(host, grpc.WithTransportCredentials(creds)) + return grpc.NewClient(host, append([]grpc.DialOption{grpc.WithTransportCredentials(creds)}, opts...)...) +} + +// cookieInterceptor carries the session cookie across gRPC calls. gRPC has no +// cookie concept, so the server sends its cookies as `set-cookie` header +// metadata and reads them back from a `cookie` metadata entry. Without this the +// MFA offer flow is unreachable over gRPC for the same reason it was over +// HTTP before the jar existed: signup/login hand out an MFA session the next +// call never replays, and SkipMfaSetup/VerifyOtp answer "invalid session". +// Cookies are stored in the client's shared jar, so a session started over one +// protocol is usable from another. +func cookieInterceptor(jar http.CookieJar, u *url.URL) grpc.UnaryClientInterceptor { + return func(ctx context.Context, method string, req, reply interface{}, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error { + if cookies := jar.Cookies(u); len(cookies) > 0 { + pairs := make([]string, 0, len(cookies)) + for _, c := range cookies { + pairs = append(pairs, c.Name+"="+c.Value) + } + ctx = metadata.AppendToOutgoingContext(ctx, "cookie", strings.Join(pairs, "; ")) + } + + var header metadata.MD + err := invoker(ctx, method, req, reply, cc, append(opts, grpc.Header(&header))...) + // Store cookies even when the call failed: a rejected MFA attempt can + // still rotate the session. + if set := header.Get("set-cookie"); len(set) > 0 { + jar.SetCookies(u, (&http.Response{Header: http.Header{"Set-Cookie": set}}).Cookies()) + } + return err + } } // stripPort removes a trailing :port from host, leaving the bare host. diff --git a/test/authorizer_test.go b/test/authorizer_test.go index 1a5e6b0..70676d3 100644 --- a/test/authorizer_test.go +++ b/test/authorizer_test.go @@ -54,6 +54,68 @@ func uniqueEmail() string { return fmt.Sprintf("test-%d@yopmail.com", rand.Int63()) } +// boolValue dereferences an optional bool flag, defaulting to false. +func boolValue(b *bool) bool { return b != nil && *b } + +// isMFAOffer reports whether an auth response is server 2.4.0's token-withheld +// MFA offer: MFA is on by default, so signup/login answer with +// "Proceed to mfa setup" plus the should_show_totp_screen / should_offer_* +// flags and NO access token. The token is only issued once the user enrolls a +// factor or explicitly declines via skip_mfa_setup. +func isMFAOffer(res *authorizer.AuthTokenResponse) bool { + return res != nil && + authorizer.StringValue(res.AccessToken) == "" && + (boolValue(res.ShouldShowTotpScreen) || + boolValue(res.ShouldOfferWebauthnMfaSetup) || + boolValue(res.ShouldOfferEmailOtpMfaSetup) || + boolValue(res.ShouldOfferSmsOtpMfaSetup)) +} + +// resolveMFAOffer turns a token-withheld MFA offer into a real auth response by +// declining the offer, which is what a client that does not want to enroll a +// second factor must do. It MUST run on the same client that made the +// signup/login call: the pending user is identified by the MFA session cookie +// that call received (gRPC carries it as `set-cookie` metadata). +// +// A response that is not an MFA offer is returned untouched, so a token that is +// missing for any other reason still fails the caller's own assertion. +func resolveMFAOffer(t *testing.T, c *authorizer.AuthorizerClient, email string, res *authorizer.AuthTokenResponse) *authorizer.AuthTokenResponse { + t.Helper() + if !isMFAOffer(res) { + return res + } + skipped, err := c.SkipMfaSetup(&authorizer.SkipMfaSetupRequest{Email: &email}) + if err != nil { + t.Fatalf("SkipMfaSetup failed while resolving the MFA offer for %s: %v", email, err) + } + return skipped +} + +// signUp creates a user and returns an auth response that carries the access +// token, resolving the default MFA offer on the way. +func signUp(t *testing.T, c *authorizer.AuthorizerClient, email string) *authorizer.AuthTokenResponse { + t.Helper() + res, err := c.SignUp(&authorizer.SignUpRequest{ + Email: &email, + Password: testPassword, + ConfirmPassword: testPassword, + }) + if err != nil { + t.Fatalf("SignUp failed: %v", err) + } + return resolveMFAOffer(t, c, email, res) +} + +// login authenticates an existing user, resolving the default MFA offer. +func login(t *testing.T, c *authorizer.AuthorizerClient, email string) *authorizer.AuthTokenResponse { + t.Helper() + res, err := c.Login(&authorizer.LoginRequest{Email: &email, Password: testPassword}) + if err != nil { + t.Fatalf("Login failed: %v", err) + } + return resolveMFAOffer(t, c, email, res) +} + func TestGetMetaData(t *testing.T) { c := testClient(t) @@ -99,22 +161,9 @@ func TestLogin(t *testing.T) { email := uniqueEmail() // Sign up first to create a user - _, err := c.SignUp(&authorizer.SignUpRequest{ - Email: &email, - Password: testPassword, - ConfirmPassword: testPassword, - }) - if err != nil { - t.Fatalf("SignUp failed (prerequisite for Login): %v", err) - } + signUp(t, c, email) - res, err := c.Login(&authorizer.LoginRequest{ - Email: &email, - Password: testPassword, - }) - if err != nil { - t.Fatalf("Login failed: %v", err) - } + res := login(t, c, email) if res == nil { t.Fatal("Login returned nil response") @@ -129,22 +178,8 @@ func TestGetProfile(t *testing.T) { email := uniqueEmail() // Sign up and login first - _, err := c.SignUp(&authorizer.SignUpRequest{ - Email: &email, - Password: testPassword, - ConfirmPassword: testPassword, - }) - if err != nil { - t.Fatalf("SignUp failed (prerequisite): %v", err) - } - - loginRes, err := c.Login(&authorizer.LoginRequest{ - Email: &email, - Password: testPassword, - }) - if err != nil { - t.Fatalf("Login failed (prerequisite): %v", err) - } + signUp(t, c, email) + loginRes := login(t, c, email) res, err := c.GetProfile(map[string]string{ "Authorization": fmt.Sprintf("Bearer %s", authorizer.StringValue(loginRes.AccessToken)), @@ -165,22 +200,8 @@ func TestGetSession(t *testing.T) { c := testClient(t) email := uniqueEmail() - _, err := c.SignUp(&authorizer.SignUpRequest{ - Email: &email, - Password: testPassword, - ConfirmPassword: testPassword, - }) - if err != nil { - t.Fatalf("SignUp failed (prerequisite): %v", err) - } - - loginRes, err := c.Login(&authorizer.LoginRequest{ - Email: &email, - Password: testPassword, - }) - if err != nil { - t.Fatalf("Login failed (prerequisite): %v", err) - } + signUp(t, c, email) + loginRes := login(t, c, email) res, err := c.GetSession(&authorizer.SessionQueryRequest{ Roles: []*string{}, @@ -204,22 +225,8 @@ func TestLogout(t *testing.T) { c := testClient(t) email := uniqueEmail() - _, err := c.SignUp(&authorizer.SignUpRequest{ - Email: &email, - Password: testPassword, - ConfirmPassword: testPassword, - }) - if err != nil { - t.Fatalf("SignUp failed (prerequisite): %v", err) - } - - loginRes, err := c.Login(&authorizer.LoginRequest{ - Email: &email, - Password: testPassword, - }) - if err != nil { - t.Fatalf("Login failed (prerequisite): %v", err) - } + signUp(t, c, email) + loginRes := login(t, c, email) res, err := c.Logout(map[string]string{ "Authorization": fmt.Sprintf("Bearer %s", authorizer.StringValue(loginRes.AccessToken)), @@ -240,22 +247,8 @@ func TestValidateJWTToken(t *testing.T) { c := testClient(t) email := uniqueEmail() - _, err := c.SignUp(&authorizer.SignUpRequest{ - Email: &email, - Password: testPassword, - ConfirmPassword: testPassword, - }) - if err != nil { - t.Fatalf("SignUp failed (prerequisite): %v", err) - } - - loginRes, err := c.Login(&authorizer.LoginRequest{ - Email: &email, - Password: testPassword, - }) - if err != nil { - t.Fatalf("Login failed (prerequisite): %v", err) - } + signUp(t, c, email) + loginRes := login(t, c, email) res, err := c.ValidateJWTToken(&authorizer.ValidateJWTTokenRequest{ TokenType: authorizer.TokenTypeAccessToken, @@ -395,22 +388,8 @@ func TestCheckPermissions(t *testing.T) { c := testClient(t) email := uniqueEmail() - _, err := c.SignUp(&authorizer.SignUpRequest{ - Email: &email, - Password: testPassword, - ConfirmPassword: testPassword, - }) - if err != nil { - t.Fatalf("SignUp failed (prerequisite): %v", err) - } - - loginRes, err := c.Login(&authorizer.LoginRequest{ - Email: &email, - Password: testPassword, - }) - if err != nil { - t.Fatalf("Login failed (prerequisite): %v", err) - } + signUp(t, c, email) + loginRes := login(t, c, email) headers := map[string]string{ "Authorization": fmt.Sprintf("Bearer %s", authorizer.StringValue(loginRes.AccessToken)), @@ -448,22 +427,8 @@ func TestListPermissions(t *testing.T) { c := testClient(t) email := uniqueEmail() - _, err := c.SignUp(&authorizer.SignUpRequest{ - Email: &email, - Password: testPassword, - ConfirmPassword: testPassword, - }) - if err != nil { - t.Fatalf("SignUp failed (prerequisite): %v", err) - } - - loginRes, err := c.Login(&authorizer.LoginRequest{ - Email: &email, - Password: testPassword, - }) - if err != nil { - t.Fatalf("Login failed (prerequisite): %v", err) - } + signUp(t, c, email) + loginRes := login(t, c, email) headers := map[string]string{ "Authorization": fmt.Sprintf("Bearer %s", authorizer.StringValue(loginRes.AccessToken)), diff --git a/test/protocol_test.go b/test/protocol_test.go index 997753e..ad4fb92 100644 --- a/test/protocol_test.go +++ b/test/protocol_test.go @@ -58,14 +58,7 @@ func TestSignUpProfileAcrossProtocols(t *testing.T) { c := protocolClient(t, p) email := uniqueEmail() - signupRes, err := c.SignUp(&authorizer.SignUpRequest{ - Email: &email, - Password: testPassword, - ConfirmPassword: testPassword, - }) - if err != nil { - t.Fatalf("[%s] SignUp failed: %v", p, err) - } + signupRes := signUp(t, c, email) if signupRes == nil || signupRes.AccessToken == nil || *signupRes.AccessToken == "" { t.Fatalf("[%s] SignUp: expected non-empty access_token, got %+v", p, signupRes) } @@ -98,19 +91,10 @@ func TestLoginAcrossProtocols(t *testing.T) { email := uniqueEmail() // Signup over graphql so every protocol's Login has a fresh account. gql := protocolClient(t, authorizer.ProtocolGraphQL) - if _, err := gql.SignUp(&authorizer.SignUpRequest{ - Email: &email, - Password: testPassword, - ConfirmPassword: testPassword, - }); err != nil { - t.Fatalf("SignUp failed: %v", err) - } + signUp(t, gql, email) c := protocolClient(t, p) - loginRes, err := c.Login(&authorizer.LoginRequest{Email: &email, Password: testPassword}) - if err != nil { - t.Fatalf("[%s] Login failed: %v", p, err) - } + loginRes := login(t, c, email) if loginRes == nil || loginRes.AccessToken == nil || *loginRes.AccessToken == "" { t.Fatalf("[%s] Login: expected non-empty access_token, got %+v", p, loginRes) } @@ -183,14 +167,7 @@ func TestUpdateProfileAcrossProtocols(t *testing.T) { t.Run(string(p), func(t *testing.T) { email := uniqueEmail() c := protocolClient(t, p) - signupRes, err := c.SignUp(&authorizer.SignUpRequest{ - Email: &email, - Password: testPassword, - ConfirmPassword: testPassword, - }) - if err != nil { - t.Fatalf("[%s] SignUp failed: %v", p, err) - } + signupRes := signUp(t, c, email) authHeader := map[string]string{ "Authorization": fmt.Sprintf("Bearer %s", authorizer.StringValue(signupRes.AccessToken)), } diff --git a/transport.go b/transport.go index e1c6ba4..2e15d9f 100644 --- a/transport.go +++ b/transport.go @@ -35,11 +35,15 @@ func outgoingContext(ctx context.Context, headers map[string]string) context.Con // // The Origin header is auto-injected for the same CSRF reason as ExecuteGraphQL. func (c *AuthorizerClient) executeREST(method, path string, body interface{}, perCallHeaders map[string]string, out interface{}) error { - return doREST(c.AuthorizerURL, method, path, body, c.ExtraHeaders, perCallHeaders, out) + // HTTPClient(), not http.DefaultClient: the REST transport must carry the + // same cookie jar as the GraphQL one, or the MFA session cookie set by + // signup/login is dropped and SkipMfaSetup/VerifyOtp fail with + // "invalid session". + return doREST(c.HTTPClient(), c.AuthorizerURL, method, path, body, c.ExtraHeaders, perCallHeaders, out) } // doREST is the shared REST executor used by both the user and admin clients. -func doREST(baseURL, method, path string, body interface{}, extraHeaders, perCallHeaders map[string]string, out interface{}) error { +func doREST(client *http.Client, baseURL, method, path string, body interface{}, extraHeaders, perCallHeaders map[string]string, out interface{}) error { var reqBody io.Reader if method == http.MethodPost && body != nil { jsonReq, err := json.Marshal(body) @@ -70,7 +74,10 @@ func doREST(baseURL, method, path string, body interface{}, extraHeaders, perCal } } - res, err := http.DefaultClient.Do(httpReq) + if client == nil { + client = http.DefaultClient + } + res, err := client.Do(httpReq) if err != nil { return err }