From 82f7ec49fa075952b78fdd6ebb33774dc9bb6312 Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Wed, 4 Sep 2024 17:12:18 +0530 Subject: [PATCH 01/15] Added missing case for processing jwt with application/jwt header --- ably/auth.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ably/auth.go b/ably/auth.go index b59b1322..1b0dc1b0 100644 --- a/ably/auth.go +++ b/ably/auth.go @@ -462,7 +462,7 @@ func (a *Auth) requestAuthURL(ctx context.Context, params *TokenParams, opts *au return nil, a.newError(40004, err) } switch typ { - case "text/plain": + case "text/plain", "application/jwt": token, err := io.ReadAll(resp.Body) if err != nil { return nil, a.newError(40000, err) From abcc68e777d971ca0dadf85b70994a891137324b Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Wed, 4 Sep 2024 17:13:42 +0530 Subject: [PATCH 02/15] Added integration test to authorize with JWT token --- ably/realtime_conn_spec_integration_test.go | 42 ++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/ably/realtime_conn_spec_integration_test.go b/ably/realtime_conn_spec_integration_test.go index 5411f0c4..a45028ad 100644 --- a/ably/realtime_conn_spec_integration_test.go +++ b/ably/realtime_conn_spec_integration_test.go @@ -5,6 +5,7 @@ package ably_test import ( "context" + "encoding/base64" "errors" "fmt" "io" @@ -3023,7 +3024,46 @@ func TestRealtimeConn_RTC8a_ExplicitAuthorizeWhileConnected(t *testing.T) { }) t.Run("RTC8a4: reauthorize with JWT token", func(t *testing.T) { - t.Skip("not implemented") + app := ablytest.MustSandbox(nil) + defer safeclose(t, app) + + key, secret := app.KeyParts() + authParams := url.Values{} + authParams.Add("environment", app.Environment) + authParams.Add("returnType", "jwt") + authParams.Add("keyName", key) + authParams.Add("keySecret", secret) + + rec, optn := ablytest.NewHttpRecorder() + rest, err := ably.NewREST( + ably.WithAuthURL("https://echo.ably.io/createJWT"), + ably.WithAuthParams(authParams), + ably.WithEnvironment(app.Environment), + ably.WithKey(""), + optn[0], + ) + + assert.NoError(t, err, "rest()=%v", err) + _, err = rest.Stats().Pages(context.Background()) + assert.NoError(t, err, "Stats()=%v", err) + + assert.Len(t, rec.Requests(), 2) + assert.Len(t, rec.Responses(), 2) + + // first request is jwt request + jwtRequest := rec.Request(0).URL + assert.Equal(t, "echo.ably.io/createJWT", jwtRequest.Host+jwtRequest.Path) + // response is jwt token + jwtResponse, err := io.ReadAll(rec.Response(0).Body) + assert.NoError(t, err) + assert.Subset(t, jwtResponse, []byte("ey")) // JWT starts with ey + + // Second request is made to stats with given jwt token (base64 encoded) + statsRequest := rec.Request(1) + assert.Equal(t, "/stats", statsRequest.URL.Path) + encodedToken := base64.StdEncoding.EncodeToString(jwtResponse) + assert.NoError(t, err) + assert.Equal(t, "Bearer "+encodedToken, statsRequest.Header.Get("Authorization")) }) t.Run("RTC8a2: Failed reauth moves connection to FAILED", func(t *testing.T) { From 2491fe9127e2e4402dedf81a7b0ccd59799c7daf Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Wed, 4 Sep 2024 17:15:38 +0530 Subject: [PATCH 03/15] Updated README, added separate authentication section under REST API --- README.md | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/README.md b/README.md index 9384c8ab..8195420d 100644 --- a/README.md +++ b/README.md @@ -326,6 +326,54 @@ if err != nil { fmt.Print(status, status.ChannelId) ``` +### Authentication +- It is recommended to use `ABLY_KEY` at server side. Check [official ably auth documentation](https://ably.com/docs/auth) for more info. +- `ABLY_KEY` should not be exposed at client side where it can be used for malicious purposes. +- Server can use `ABLY_KEY` for initializing the `AblyRest` instance. + +```go +restClient, err := ably.NewREST(ably.WithKey("API_KEY")) +``` +- Token requests are issued by your servers and signed using your private API key. +- This is the preferred method of authentication as no secrets are ever shared, and the token request can be issued to trusted clients without communicating with Ably. +```go +// e.g. Gin server endpoint +router.GET("/token", getToken) +func getToken(c *gin.Context) { + token, err := restClient.Auth.CreateTokenRequest(nil) + c.IndentedJSON(http.StatusOK, token) +} + +``` +- You can also return JWT string token signed using `ABLY_KEY` as per [official ably JWT doc](https://ably.com/tutorials/jwt-authentication). When using `WithAuthURL` clientOption at client side, response contentType header should be set to either `text/plain` or `application/jwt`. + +### Using the Token auth at client side + +- You provide either `WithAuthCallback` or `WithAuthURL` as a clientOption to request token. + +```go +authCallback := ably.WithAuthCallback(func(ctx context.Context, tp ably.TokenParams) (ably.Tokener, error) { + // HTTP client impl. to fetch token, you can pass tokenParams based on your requirement + token, err := requestTokenFrom(ctx, "/token"); + if err != nil { + return nil, err // You can also log error here + } + return token, err +}) + +``` +- If JWT token is returned by server +```go +authCallback := ably.WithAuthCallback(func(ctx context.Context, tp ably.TokenParams) (ably.Tokener, error) { + jwtTokenString, err := requestTokenFrom(ctx, "/jwtToken"); + if err != nil { + return nil, err // You can also log error here + } + return jwtTokenString, err // return standard jwt base64 encoded token that starts with "ey" +}) +``` +- Check [official token auth documentation](https://ably.com/docs/auth/token?lang=csharp) for more information. + ### Configure logging - By default, internal logger prints output to `stdout` with default logging level of `warning`. - You need to create a custom Logger that implements `ably.Logger` interface. From 8159c05e52cd4bdb1fcafd1cd47260e856cf85ad Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Wed, 4 Sep 2024 17:16:20 +0530 Subject: [PATCH 04/15] Updated README, removed limitation for jwt auth --- README.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/README.md b/README.md index 8195420d..54447907 100644 --- a/README.md +++ b/README.md @@ -490,9 +490,6 @@ As of release 1.2.0, the following are not implemented and will be covered in fu - [Push notifications admin API](https://ably.com/docs/api/rest-sdk/push-admin) is not implemented. -- [JWT authentication](https://ably.com/docs/auth/token?lang=javascript#jwt) using `auth-url` is not implemented. -See [jwt auth issue](https://github.com/ably/ably-go/issues/569) for more details. - ### Realtime API - Channel suspended state is partially implemented. See [suspended channel state](https://github.com/ably/ably-go/issues/568). From 52e16c3a8974d624af45bedff3a9ebfc275306fd Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Wed, 4 Sep 2024 18:47:05 +0530 Subject: [PATCH 05/15] Added sandbox methods for jwtAuthParams and creatingJWT --- ably/realtime_conn_spec_integration_test.go | 13 ++------ ablytest/sandbox.go | 37 +++++++++++++++++++++ 2 files changed, 40 insertions(+), 10 deletions(-) diff --git a/ably/realtime_conn_spec_integration_test.go b/ably/realtime_conn_spec_integration_test.go index a45028ad..e1d187cb 100644 --- a/ably/realtime_conn_spec_integration_test.go +++ b/ably/realtime_conn_spec_integration_test.go @@ -3027,17 +3027,10 @@ func TestRealtimeConn_RTC8a_ExplicitAuthorizeWhileConnected(t *testing.T) { app := ablytest.MustSandbox(nil) defer safeclose(t, app) - key, secret := app.KeyParts() - authParams := url.Values{} - authParams.Add("environment", app.Environment) - authParams.Add("returnType", "jwt") - authParams.Add("keyName", key) - authParams.Add("keySecret", secret) - rec, optn := ablytest.NewHttpRecorder() rest, err := ably.NewREST( - ably.WithAuthURL("https://echo.ably.io/createJWT"), - ably.WithAuthParams(authParams), + ably.WithAuthURL(ablytest.CREATE_JWT_URL), + ably.WithAuthParams(app.GetJwtAuthParams(30*time.Second)), ably.WithEnvironment(app.Environment), ably.WithKey(""), optn[0], @@ -3052,7 +3045,7 @@ func TestRealtimeConn_RTC8a_ExplicitAuthorizeWhileConnected(t *testing.T) { // first request is jwt request jwtRequest := rec.Request(0).URL - assert.Equal(t, "echo.ably.io/createJWT", jwtRequest.Host+jwtRequest.Path) + assert.Equal(t, ablytest.CREATE_JWT_URL, "https://"+jwtRequest.Host+jwtRequest.Path) // response is jwt token jwtResponse, err := io.ReadAll(rec.Response(0).Body) assert.NoError(t, err) diff --git a/ablytest/sandbox.go b/ablytest/sandbox.go index 469e47bd..9dc90901 100644 --- a/ablytest/sandbox.go +++ b/ablytest/sandbox.go @@ -256,6 +256,43 @@ func (app *Sandbox) URL(paths ...string) string { return "https://" + app.Environment + "-rest.ably.io/" + path.Join(paths...) } +// Source code for the same => https://github.com/ably/echoserver/blob/main/app.js +var CREATE_JWT_URL string = "https://echo.ably.io/createJWT" + +// Returns authParams, required for authUrl as a mode of auth +func (app *Sandbox) GetJwtAuthParams(expiresIn time.Duration) url.Values { + key, secret := app.KeyParts() + authParams := url.Values{} + authParams.Add("environment", app.Environment) + authParams.Add("returnType", "jwt") + authParams.Add("keyName", key) + authParams.Add("keySecret", secret) + authParams.Add("expiresIn", fmt.Sprint(expiresIn.Milliseconds())) + return authParams +} + +// Returns JWT with given expiry +func (app *Sandbox) CreateJwt(expiresIn time.Duration) (string, error) { + u, err := url.Parse(CREATE_JWT_URL) + if err != nil { + return "", err + } + u.RawQuery = app.GetJwtAuthParams(expiresIn).Encode() + req, err := http.NewRequest(http.MethodGet, u.String(), nil) + if err != nil { + return "", fmt.Errorf("client: could not create request: %s", err) + } + res, err := app.client.Do(req) + if err != nil { + return "", fmt.Errorf("client: error making http request: %s", err) + } + resBody, err := io.ReadAll(res.Body) + if err != nil { + return "", fmt.Errorf("client: could not read response body: %s", err) + } + return string(resBody), nil +} + func NewHTTPClient() *http.Client { const timeout = time.Minute return &http.Client{ From 3111fc49e25b0f0ae247cdfe96049dfa16f2aeef Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Wed, 4 Sep 2024 22:39:05 +0530 Subject: [PATCH 06/15] Added more integration tests for JWT based auth, set expiresIn to seconds --- ably/auth_integration_test.go | 83 +++++++++++++++++++++ ably/realtime_conn_spec_integration_test.go | 72 +++++++++++------- ablytest/sandbox.go | 2 +- 3 files changed, 128 insertions(+), 29 deletions(-) diff --git a/ably/auth_integration_test.go b/ably/auth_integration_test.go index 6803b1d6..3f4fce7a 100644 --- a/ably/auth_integration_test.go +++ b/ably/auth_integration_test.go @@ -4,10 +4,12 @@ package ably_test import ( + "bytes" "context" "encoding/base64" "errors" "fmt" + "io" "net/http" "net/url" "strings" @@ -342,6 +344,87 @@ func TestAuth_RequestToken(t *testing.T) { } } +func TestAuth_JWT_Token(t *testing.T) { + + t.Run("Should be able to authenticate using authURL", func(t *testing.T) { + app := ablytest.MustSandbox(nil) + defer safeclose(t, app) + + rec, optn := ablytest.NewHttpRecorder() + rest, err := ably.NewREST( + ably.WithAuthURL(ablytest.CREATE_JWT_URL), + ably.WithAuthParams(app.GetJwtAuthParams(30*time.Second)), + ably.WithEnvironment(app.Environment), + ably.WithKey(""), + optn[0], + ) + + assert.NoError(t, err, "rest()=%v", err) + _, err = rest.Stats().Pages(context.Background()) + assert.NoError(t, err, "Stats()=%v", err) + + assert.Len(t, rec.Requests(), 2) + assert.Len(t, rec.Responses(), 2) + + // first request is jwt request + jwtRequest := rec.Request(0).URL + assert.Equal(t, ablytest.CREATE_JWT_URL, "https://"+jwtRequest.Host+jwtRequest.Path) + // response is jwt token + jwtResponse, err := io.ReadAll(rec.Response(0).Body) + assert.NoError(t, err) + assert.True(t, bytes.HasPrefix(jwtResponse, []byte("ey"))) + + // Second request is made to stats with given jwt token (base64 encoded) + statsRequest := rec.Request(1) + assert.Equal(t, "/stats", statsRequest.URL.Path) + encodedToken := base64.StdEncoding.EncodeToString(jwtResponse) + assert.NoError(t, err) + assert.Equal(t, "Bearer "+encodedToken, statsRequest.Header.Get("Authorization")) + }) + + t.Run("Should be able to authenticate using authCallback", func(t *testing.T) { + app := ablytest.MustSandbox(nil) + defer safeclose(t, app) + + jwtToken := "" + authCallback := ably.WithAuthCallback(func(ctx context.Context, tp ably.TokenParams) (ably.Tokener, error) { + jwtTokenString, err := app.CreateJwt(time.Second * 30) + jwtToken = jwtTokenString + if err != nil { + return nil, err + } + return ably.TokenString(jwtTokenString), nil + }) + + rec, optn := ablytest.NewHttpRecorder() + rest, err := ably.NewREST( + ably.WithEnvironment(app.Environment), + ably.WithKey(""), + authCallback, + optn[0], + ) + + assert.NoError(t, err, "rest()=%v", err) + _, err = rest.Stats().Pages(context.Background()) + assert.NoError(t, err, "Stats()=%v", err) + + assert.Len(t, rec.Requests(), 1) + assert.Len(t, rec.Responses(), 1) + + assert.True(t, strings.HasPrefix(jwtToken, "ey")) + // Second request is made to stats with given jwt token (base64 encoded) + statsRequest := rec.Request(0) + assert.Equal(t, "/stats", statsRequest.URL.Path) + encodedToken := base64.StdEncoding.EncodeToString([]byte(jwtToken)) + assert.NoError(t, err) + assert.Equal(t, "Bearer "+encodedToken, statsRequest.Header.Get("Authorization")) + }) + + t.Run("Should return error when JWT is invalid", func(t *testing.T) { + + }) +} + func TestAuth_ReuseClientID(t *testing.T) { opts := []ably.ClientOption{ably.WithUseTokenAuth(true)} app, client := ablytest.NewREST(opts...) diff --git a/ably/realtime_conn_spec_integration_test.go b/ably/realtime_conn_spec_integration_test.go index e1d187cb..c007ae26 100644 --- a/ably/realtime_conn_spec_integration_test.go +++ b/ably/realtime_conn_spec_integration_test.go @@ -5,7 +5,6 @@ package ably_test import ( "context" - "encoding/base64" "errors" "fmt" "io" @@ -3024,39 +3023,56 @@ func TestRealtimeConn_RTC8a_ExplicitAuthorizeWhileConnected(t *testing.T) { }) t.Run("RTC8a4: reauthorize with JWT token", func(t *testing.T) { + t.Parallel() app := ablytest.MustSandbox(nil) defer safeclose(t, app) - rec, optn := ablytest.NewHttpRecorder() - rest, err := ably.NewREST( - ably.WithAuthURL(ablytest.CREATE_JWT_URL), - ably.WithAuthParams(app.GetJwtAuthParams(30*time.Second)), - ably.WithEnvironment(app.Environment), - ably.WithKey(""), - optn[0], - ) - - assert.NoError(t, err, "rest()=%v", err) - _, err = rest.Stats().Pages(context.Background()) - assert.NoError(t, err, "Stats()=%v", err) - - assert.Len(t, rec.Requests(), 2) - assert.Len(t, rec.Responses(), 2) - - // first request is jwt request - jwtRequest := rec.Request(0).URL - assert.Equal(t, ablytest.CREATE_JWT_URL, "https://"+jwtRequest.Host+jwtRequest.Path) - // response is jwt token - jwtResponse, err := io.ReadAll(rec.Response(0).Body) + authCallbackTokens := []string{} + tokenExpiry := 3 * time.Second + // Returns token that expires after 3 seconds causing disconnect every 3 seconds + authCallback := func(ctx context.Context, tp ably.TokenParams) (ably.Tokener, error) { + jwtTokenString, err := app.CreateJwt(tokenExpiry) + if err != nil { + return nil, err + } + authCallbackTokens = append(authCallbackTokens, jwtTokenString) + return ably.TokenString(jwtTokenString), nil + } + + realtime, err := ably.NewRealtime( + ably.WithAutoConnect(false), + ably.WithEnvironment(ablytest.Environment), + ably.WithAuthCallback(authCallback)) + assert.NoError(t, err) - assert.Subset(t, jwtResponse, []byte("ey")) // JWT starts with ey + defer realtime.Close() - // Second request is made to stats with given jwt token (base64 encoded) - statsRequest := rec.Request(1) - assert.Equal(t, "/stats", statsRequest.URL.Path) - encodedToken := base64.StdEncoding.EncodeToString(jwtResponse) + err = ablytest.Wait(ablytest.ConnWaiter(realtime, realtime.Connect, ably.ConnectionEventConnected), nil) assert.NoError(t, err) - assert.Equal(t, "Bearer "+encodedToken, statsRequest.Header.Get("Authorization")) + + changes := make(ably.ConnStateChanges, 2) + off := realtime.Connection.OnAll(changes.Receive) + defer off() + var state ably.ConnectionStateChange + + // Disconnects due to timeout + ablytest.Soon.Recv(t, &state, changes, t.Fatalf) + assert.Equal(t, ably.ConnectionEventDisconnected, state.Event) + // Reconnect again using new JWT token + ablytest.Soon.Recv(t, &state, changes, t.Fatalf) + assert.Equal(t, ably.ConnectionEventConnecting, state.Event) + ablytest.Soon.Recv(t, &state, changes, t.Fatalf) + assert.Equal(t, ably.ConnectionEventConnected, state.Event) + assert.Nil(t, state.Reason) + assert.Equal(t, ably.ConnectionStateConnected, realtime.Connection.State()) + + ablytest.Instantly.NoRecv(t, nil, changes, t.Fatalf) + + // Make sure requested tokens are JWT tokens + assert.Len(t, authCallbackTokens, 2) + assert.True(t, strings.HasPrefix(authCallbackTokens[0], "ey")) + assert.True(t, strings.HasPrefix(authCallbackTokens[1], "ey")) + assertUnique(t, authCallbackTokens) }) t.Run("RTC8a2: Failed reauth moves connection to FAILED", func(t *testing.T) { diff --git a/ablytest/sandbox.go b/ablytest/sandbox.go index 9dc90901..36596351 100644 --- a/ablytest/sandbox.go +++ b/ablytest/sandbox.go @@ -267,7 +267,7 @@ func (app *Sandbox) GetJwtAuthParams(expiresIn time.Duration) url.Values { authParams.Add("returnType", "jwt") authParams.Add("keyName", key) authParams.Add("keySecret", secret) - authParams.Add("expiresIn", fmt.Sprint(expiresIn.Milliseconds())) + authParams.Add("expiresIn", fmt.Sprint(expiresIn.Seconds())) return authParams } From 1ffeb0f63aad47a910a1965a721ae5c2fb9a693e Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Wed, 4 Sep 2024 22:42:20 +0530 Subject: [PATCH 07/15] Updated README, authentication section updated with AuthURL, added few comments --- README.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 54447907..a63c09e0 100644 --- a/README.md +++ b/README.md @@ -345,15 +345,20 @@ func getToken(c *gin.Context) { } ``` -- You can also return JWT string token signed using `ABLY_KEY` as per [official ably JWT doc](https://ably.com/tutorials/jwt-authentication). When using `WithAuthURL` clientOption at client side, response contentType header should be set to either `text/plain` or `application/jwt`. + +- You can also return JWT string token signed using `ABLY_KEY` as per [official ably JWT doc](https://ably.com/tutorials/jwt-authentication). +- When using `WithAuthURL` clientOption at client side, for JWT response, contentType header should be set to `text/plain` or `application/jwt`. For `ably.TokenRequest`/ `ably.TokenDetails`, set it as `application/json`. ### Using the Token auth at client side -- You provide either `WithAuthCallback` or `WithAuthURL` as a clientOption to request token. +- You can provide either `WithAuthCallback` or `WithAuthURL` as a clientOption to request token. +- `WithAuthUrl` automatically decodes response based on response contentType. ```go authCallback := ably.WithAuthCallback(func(ctx context.Context, tp ably.TokenParams) (ably.Tokener, error) { // HTTP client impl. to fetch token, you can pass tokenParams based on your requirement + // Return token of type ably.TokenDetails, ably.TokenRequest or ably.TokenString + // This may need manually decoding tokens based on the response. token, err := requestTokenFrom(ctx, "/token"); if err != nil { return nil, err // You can also log error here @@ -369,7 +374,7 @@ authCallback := ably.WithAuthCallback(func(ctx context.Context, tp ably.TokenPar if err != nil { return nil, err // You can also log error here } - return jwtTokenString, err // return standard jwt base64 encoded token that starts with "ey" + return ably.TokenString(jwtTokenString), err // return jwt token that starts with "ey" }) ``` - Check [official token auth documentation](https://ably.com/docs/auth/token?lang=csharp) for more information. From 3bc352c32f4eb216b24cf258d1ec5c9214e8deab Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Thu, 5 Sep 2024 13:58:16 +0530 Subject: [PATCH 08/15] Updated JWT tests with right spec annotations, added missing assertions --- ably/auth_integration_test.go | 14 +++++++++++--- ably/realtime_conn_spec_integration_test.go | 8 +++++++- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/ably/auth_integration_test.go b/ably/auth_integration_test.go index 3f4fce7a..b19ad264 100644 --- a/ably/auth_integration_test.go +++ b/ably/auth_integration_test.go @@ -344,9 +344,17 @@ func TestAuth_RequestToken(t *testing.T) { } } -func TestAuth_JWT_Token(t *testing.T) { +func TestAuth_JWT_Token_RSA8c(t *testing.T) { - t.Run("Should be able to authenticate using authURL", func(t *testing.T) { + t.Run("Get JWT from echo server", func(t *testing.T) { + app := ablytest.MustSandbox(nil) + defer safeclose(t, app) + jwt, err := app.CreateJwt(3 * time.Second) + assert.NoError(t, err) + assert.True(t, strings.HasPrefix(jwt, "ey")) + }) + + t.Run("RSA8g, RSA3d: Should be able to authenticate using authURL", func(t *testing.T) { app := ablytest.MustSandbox(nil) defer safeclose(t, app) @@ -382,7 +390,7 @@ func TestAuth_JWT_Token(t *testing.T) { assert.Equal(t, "Bearer "+encodedToken, statsRequest.Header.Get("Authorization")) }) - t.Run("Should be able to authenticate using authCallback", func(t *testing.T) { + t.Run("RSA8g, RSA3d: Should be able to authenticate using authCallback", func(t *testing.T) { app := ablytest.MustSandbox(nil) defer safeclose(t, app) diff --git a/ably/realtime_conn_spec_integration_test.go b/ably/realtime_conn_spec_integration_test.go index c007ae26..838de2d9 100644 --- a/ably/realtime_conn_spec_integration_test.go +++ b/ably/realtime_conn_spec_integration_test.go @@ -3022,7 +3022,7 @@ func TestRealtimeConn_RTC8a_ExplicitAuthorizeWhileConnected(t *testing.T) { ablytest.Instantly.Recv(t, nil, authorizeDone, t.Fatalf) }) - t.Run("RTC8a4: reauthorize with JWT token", func(t *testing.T) { + t.Run("RTC8a4, RSA3d: reauthorize with JWT token", func(t *testing.T) { t.Parallel() app := ablytest.MustSandbox(nil) defer safeclose(t, app) @@ -3039,9 +3039,11 @@ func TestRealtimeConn_RTC8a_ExplicitAuthorizeWhileConnected(t *testing.T) { return ably.TokenString(jwtTokenString), nil } + realtimeMsgRecorder := NewMessageRecorder() realtime, err := ably.NewRealtime( ably.WithAutoConnect(false), ably.WithEnvironment(ablytest.Environment), + ably.WithDial(realtimeMsgRecorder.Dial), ably.WithAuthCallback(authCallback)) assert.NoError(t, err) @@ -3073,6 +3075,10 @@ func TestRealtimeConn_RTC8a_ExplicitAuthorizeWhileConnected(t *testing.T) { assert.True(t, strings.HasPrefix(authCallbackTokens[0], "ey")) assert.True(t, strings.HasPrefix(authCallbackTokens[1], "ey")) assertUnique(t, authCallbackTokens) + // 2 Dial attempts made + assert.Len(t, realtimeMsgRecorder.URLs(), 2) + assert.Equal(t, authCallbackTokens[0], realtimeMsgRecorder.URLs()[0].Query().Get("access_token")) + assert.Equal(t, authCallbackTokens[1], realtimeMsgRecorder.URLs()[1].Query().Get("access_token")) }) t.Run("RTC8a2: Failed reauth moves connection to FAILED", func(t *testing.T) { From 13fc7fb6670b241c479fe6d5f37a885b1f9076ae Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Thu, 5 Sep 2024 15:16:45 +0530 Subject: [PATCH 09/15] Updated CreateJwt method under sandbox, added invalid bool param, added test for the same --- ably/auth_integration_test.go | 30 ++++++++++++++++++--- ably/realtime_conn_spec_integration_test.go | 2 +- ablytest/sandbox.go | 12 ++++++--- 3 files changed, 35 insertions(+), 9 deletions(-) diff --git a/ably/auth_integration_test.go b/ably/auth_integration_test.go index b19ad264..821b1f4a 100644 --- a/ably/auth_integration_test.go +++ b/ably/auth_integration_test.go @@ -349,7 +349,7 @@ func TestAuth_JWT_Token_RSA8c(t *testing.T) { t.Run("Get JWT from echo server", func(t *testing.T) { app := ablytest.MustSandbox(nil) defer safeclose(t, app) - jwt, err := app.CreateJwt(3 * time.Second) + jwt, err := app.CreateJwt(3*time.Second, false) assert.NoError(t, err) assert.True(t, strings.HasPrefix(jwt, "ey")) }) @@ -361,7 +361,7 @@ func TestAuth_JWT_Token_RSA8c(t *testing.T) { rec, optn := ablytest.NewHttpRecorder() rest, err := ably.NewREST( ably.WithAuthURL(ablytest.CREATE_JWT_URL), - ably.WithAuthParams(app.GetJwtAuthParams(30*time.Second)), + ably.WithAuthParams(app.GetJwtAuthParams(30*time.Second, false)), ably.WithEnvironment(app.Environment), ably.WithKey(""), optn[0], @@ -396,7 +396,7 @@ func TestAuth_JWT_Token_RSA8c(t *testing.T) { jwtToken := "" authCallback := ably.WithAuthCallback(func(ctx context.Context, tp ably.TokenParams) (ably.Tokener, error) { - jwtTokenString, err := app.CreateJwt(time.Second * 30) + jwtTokenString, err := app.CreateJwt(time.Second*30, false) jwtToken = jwtTokenString if err != nil { return nil, err @@ -428,8 +428,30 @@ func TestAuth_JWT_Token_RSA8c(t *testing.T) { assert.Equal(t, "Bearer "+encodedToken, statsRequest.Header.Get("Authorization")) }) - t.Run("Should return error when JWT is invalid", func(t *testing.T) { + t.Run("RSA4e, RSA4b: Should return error when JWT is invalid", func(t *testing.T) { + app := ablytest.MustSandbox(nil) + defer safeclose(t, app) + rec, optn := ablytest.NewHttpRecorder() + rest, err := ably.NewREST( + ably.WithAuthURL(ablytest.CREATE_JWT_URL), + ably.WithAuthParams(app.GetJwtAuthParams(30*time.Second, true)), + ably.WithEnvironment(app.Environment), + ably.WithKey(""), + optn[0], + ) + + assert.NoError(t, err, "rest()=%v", err) + _, err = rest.Stats().Pages(context.Background()) + var errorInfo *ably.ErrorInfo + assert.Error(t, err, "Stats()=%v", err) + assert.ErrorAs(t, err, &errorInfo) + assert.Equal(t, 40144, int(errorInfo.Code)) + assert.Equal(t, 401, errorInfo.StatusCode) + assert.Contains(t, err.Error(), "invalid JWT format") + + assert.Len(t, rec.Requests(), 2) + assert.Len(t, rec.Responses(), 2) }) } diff --git a/ably/realtime_conn_spec_integration_test.go b/ably/realtime_conn_spec_integration_test.go index 838de2d9..eb4be2fa 100644 --- a/ably/realtime_conn_spec_integration_test.go +++ b/ably/realtime_conn_spec_integration_test.go @@ -3031,7 +3031,7 @@ func TestRealtimeConn_RTC8a_ExplicitAuthorizeWhileConnected(t *testing.T) { tokenExpiry := 3 * time.Second // Returns token that expires after 3 seconds causing disconnect every 3 seconds authCallback := func(ctx context.Context, tp ably.TokenParams) (ably.Tokener, error) { - jwtTokenString, err := app.CreateJwt(tokenExpiry) + jwtTokenString, err := app.CreateJwt(tokenExpiry, false) if err != nil { return nil, err } diff --git a/ablytest/sandbox.go b/ablytest/sandbox.go index 36596351..b96e3100 100644 --- a/ablytest/sandbox.go +++ b/ablytest/sandbox.go @@ -260,24 +260,28 @@ func (app *Sandbox) URL(paths ...string) string { var CREATE_JWT_URL string = "https://echo.ably.io/createJWT" // Returns authParams, required for authUrl as a mode of auth -func (app *Sandbox) GetJwtAuthParams(expiresIn time.Duration) url.Values { +func (app *Sandbox) GetJwtAuthParams(expiresIn time.Duration, invalid bool) url.Values { key, secret := app.KeyParts() authParams := url.Values{} authParams.Add("environment", app.Environment) authParams.Add("returnType", "jwt") authParams.Add("keyName", key) - authParams.Add("keySecret", secret) + if invalid { + authParams.Add("keySecret", "invalid") + } else { + authParams.Add("keySecret", secret) + } authParams.Add("expiresIn", fmt.Sprint(expiresIn.Seconds())) return authParams } // Returns JWT with given expiry -func (app *Sandbox) CreateJwt(expiresIn time.Duration) (string, error) { +func (app *Sandbox) CreateJwt(expiresIn time.Duration, invalid bool) (string, error) { u, err := url.Parse(CREATE_JWT_URL) if err != nil { return "", err } - u.RawQuery = app.GetJwtAuthParams(expiresIn).Encode() + u.RawQuery = app.GetJwtAuthParams(expiresIn, invalid).Encode() req, err := http.NewRequest(http.MethodGet, u.String(), nil) if err != nil { return "", fmt.Errorf("client: could not create request: %s", err) From cbd39b6906b1e8fa2a3420b197c52efc016b2d7c Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Thu, 5 Sep 2024 16:14:28 +0530 Subject: [PATCH 10/15] Added test to retrieve token from echo server and use it as a token --- ably/auth_integration_test.go | 35 +++++++++++++++++++++++++---------- 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/ably/auth_integration_test.go b/ably/auth_integration_test.go index 821b1f4a..b8a776d4 100644 --- a/ably/auth_integration_test.go +++ b/ably/auth_integration_test.go @@ -346,12 +346,32 @@ func TestAuth_RequestToken(t *testing.T) { func TestAuth_JWT_Token_RSA8c(t *testing.T) { - t.Run("Get JWT from echo server", func(t *testing.T) { + t.Run("Get JWT from echo server and use it as token", func(t *testing.T) { app := ablytest.MustSandbox(nil) defer safeclose(t, app) jwt, err := app.CreateJwt(3*time.Second, false) assert.NoError(t, err) assert.True(t, strings.HasPrefix(jwt, "ey")) + + rec, optn := ablytest.NewHttpRecorder() + rest, err := ably.NewREST( + ably.WithToken(jwt), + ably.WithEnvironment(app.Environment), + ably.WithKey(""), + optn[0], + ) + assert.NoError(t, err, "rest()=%v", err) + + _, err = rest.Stats().Pages(context.Background()) + assert.NoError(t, err, "Stats()=%v", err) + + assert.Len(t, rec.Requests(), 1) + assert.Len(t, rec.Responses(), 1) + + statsRequest := rec.Request(0) + assert.Equal(t, "/stats", statsRequest.URL.Path) + encodedToken := base64.StdEncoding.EncodeToString([]byte(jwt)) + assert.Equal(t, "Bearer "+encodedToken, statsRequest.Header.Get("Authorization")) }) t.Run("RSA8g, RSA3d: Should be able to authenticate using authURL", func(t *testing.T) { @@ -363,11 +383,10 @@ func TestAuth_JWT_Token_RSA8c(t *testing.T) { ably.WithAuthURL(ablytest.CREATE_JWT_URL), ably.WithAuthParams(app.GetJwtAuthParams(30*time.Second, false)), ably.WithEnvironment(app.Environment), - ably.WithKey(""), optn[0], ) - assert.NoError(t, err, "rest()=%v", err) + _, err = rest.Stats().Pages(context.Background()) assert.NoError(t, err, "Stats()=%v", err) @@ -386,7 +405,6 @@ func TestAuth_JWT_Token_RSA8c(t *testing.T) { statsRequest := rec.Request(1) assert.Equal(t, "/stats", statsRequest.URL.Path) encodedToken := base64.StdEncoding.EncodeToString(jwtResponse) - assert.NoError(t, err) assert.Equal(t, "Bearer "+encodedToken, statsRequest.Header.Get("Authorization")) }) @@ -407,12 +425,11 @@ func TestAuth_JWT_Token_RSA8c(t *testing.T) { rec, optn := ablytest.NewHttpRecorder() rest, err := ably.NewREST( ably.WithEnvironment(app.Environment), - ably.WithKey(""), authCallback, optn[0], ) + assert.NoError(t, err) - assert.NoError(t, err, "rest()=%v", err) _, err = rest.Stats().Pages(context.Background()) assert.NoError(t, err, "Stats()=%v", err) @@ -424,7 +441,6 @@ func TestAuth_JWT_Token_RSA8c(t *testing.T) { statsRequest := rec.Request(0) assert.Equal(t, "/stats", statsRequest.URL.Path) encodedToken := base64.StdEncoding.EncodeToString([]byte(jwtToken)) - assert.NoError(t, err) assert.Equal(t, "Bearer "+encodedToken, statsRequest.Header.Get("Authorization")) }) @@ -437,14 +453,13 @@ func TestAuth_JWT_Token_RSA8c(t *testing.T) { ably.WithAuthURL(ablytest.CREATE_JWT_URL), ably.WithAuthParams(app.GetJwtAuthParams(30*time.Second, true)), ably.WithEnvironment(app.Environment), - ably.WithKey(""), optn[0], ) - assert.NoError(t, err, "rest()=%v", err) + _, err = rest.Stats().Pages(context.Background()) var errorInfo *ably.ErrorInfo - assert.Error(t, err, "Stats()=%v", err) + assert.Error(t, err) assert.ErrorAs(t, err, &errorInfo) assert.Equal(t, 40144, int(errorInfo.Code)) assert.Equal(t, 401, errorInfo.StatusCode) From 1ca24afd1d21a3ba74ad01d5c2e52a4bb89c218c Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Fri, 6 Sep 2024 16:07:02 +0530 Subject: [PATCH 11/15] Updated sandbox GetJWTAuthParams and CreateJwt method as per review comments --- ablytest/sandbox.go | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/ablytest/sandbox.go b/ablytest/sandbox.go index b96e3100..11220d03 100644 --- a/ablytest/sandbox.go +++ b/ablytest/sandbox.go @@ -259,7 +259,14 @@ func (app *Sandbox) URL(paths ...string) string { // Source code for the same => https://github.com/ably/echoserver/blob/main/app.js var CREATE_JWT_URL string = "https://echo.ably.io/createJWT" -// Returns authParams, required for authUrl as a mode of auth +// GetJwtAuthParams constructs the authentication parameters required for JWT creation. +// Required when authUrl is chosen as a mode of auth +// +// Parameters: +// - expiresIn: The duration until the JWT expires. +// - invalid: A boolean flag indicating whether to use an invalid key secret. +// +// Returns: A url.Values object containing the authentication parameters. func (app *Sandbox) GetJwtAuthParams(expiresIn time.Duration, invalid bool) url.Values { key, secret := app.KeyParts() authParams := url.Values{} @@ -275,7 +282,15 @@ func (app *Sandbox) GetJwtAuthParams(expiresIn time.Duration, invalid bool) url. return authParams } -// Returns JWT with given expiry +// CreateJwt generates a JWT with the specified expiration time. +// +// Parameters: +// - expiresIn: The duration until the JWT expires. +// - invalid: A boolean flag indicating whether to use an invalid key secret. +// +// Returns: +// - A string containing the generated JWT. +// - An error if the JWT creation fails. func (app *Sandbox) CreateJwt(expiresIn time.Duration, invalid bool) (string, error) { u, err := url.Parse(CREATE_JWT_URL) if err != nil { @@ -288,12 +303,17 @@ func (app *Sandbox) CreateJwt(expiresIn time.Duration, invalid bool) (string, er } res, err := app.client.Do(req) if err != nil { + res.Body.Close() return "", fmt.Errorf("client: error making http request: %s", err) } + defer res.Body.Close() resBody, err := io.ReadAll(res.Body) if err != nil { return "", fmt.Errorf("client: could not read response body: %s", err) } + if res.StatusCode != 200 { + return "", fmt.Errorf("non-success response received: %v:%s", res.StatusCode, resBody) + } return string(resBody), nil } From d9d1f536b7b528fefd97cbb0d0799f8e4be54d14 Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Sat, 7 Sep 2024 22:50:00 +0530 Subject: [PATCH 12/15] Added separate test to check for decoded JWT contents --- ably/auth_integration_test.go | 36 +++++++++++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/ably/auth_integration_test.go b/ably/auth_integration_test.go index b8a776d4..a3dda2f5 100644 --- a/ably/auth_integration_test.go +++ b/ably/auth_integration_test.go @@ -7,6 +7,7 @@ import ( "bytes" "context" "encoding/base64" + "encoding/json" "errors" "fmt" "io" @@ -346,7 +347,39 @@ func TestAuth_RequestToken(t *testing.T) { func TestAuth_JWT_Token_RSA8c(t *testing.T) { - t.Run("Get JWT from echo server and use it as token", func(t *testing.T) { + t.Run("Get JWT from echo server", func(t *testing.T) { + app := ablytest.MustSandbox(nil) + defer safeclose(t, app) + jwt, err := app.CreateJwt(3*time.Second, false) + assert.NoError(t, err) + assert.True(t, strings.HasPrefix(jwt, "ey")) + + // JWT header assertions + header := strings.Split(jwt, ".")[0] + jwtToken, err := base64.RawStdEncoding.DecodeString(header) + assert.NoError(t, err) + var result map[string]interface{} + err = json.Unmarshal(jwtToken, &result) + assert.NoError(t, err) + assert.Equal(t, "HS256", result["alg"]) + assert.Equal(t, "JWT", result["typ"]) + assert.Contains(t, result, "kid") + assert.NoError(t, err) + + // JWT payload assertions + payload := strings.Split(jwt, ".")[1] + jwtToken, err = base64.RawStdEncoding.DecodeString(payload) + assert.NoError(t, err) + err = json.Unmarshal(jwtToken, &result) + assert.NoError(t, err) + assert.Contains(t, result, "iat") + assert.Contains(t, result, "exp") + + // check expiry of 3 seconds + assert.Equal(t, float64(3), result["exp"].(float64)-result["iat"].(float64)) + }) + + t.Run("Should be able to use it as a token", func(t *testing.T) { app := ablytest.MustSandbox(nil) defer safeclose(t, app) jwt, err := app.CreateJwt(3*time.Second, false) @@ -357,7 +390,6 @@ func TestAuth_JWT_Token_RSA8c(t *testing.T) { rest, err := ably.NewREST( ably.WithToken(jwt), ably.WithEnvironment(app.Environment), - ably.WithKey(""), optn[0], ) assert.NoError(t, err, "rest()=%v", err) From b17ced21e0783a4ee57ff8f095529fc8b6c51726 Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Sat, 7 Sep 2024 22:57:42 +0530 Subject: [PATCH 13/15] Added jwt authcallback error check to integration test as per review comment --- ably/auth_integration_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/ably/auth_integration_test.go b/ably/auth_integration_test.go index a3dda2f5..4788f6b7 100644 --- a/ably/auth_integration_test.go +++ b/ably/auth_integration_test.go @@ -449,6 +449,7 @@ func TestAuth_JWT_Token_RSA8c(t *testing.T) { jwtTokenString, err := app.CreateJwt(time.Second*30, false) jwtToken = jwtTokenString if err != nil { + t.Fatalf("Error creating JWT: %v", err) return nil, err } return ably.TokenString(jwtTokenString), nil From 4926542d120e09bce434fe650b01934ffad1c3aa Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Mon, 9 Sep 2024 13:33:51 +0530 Subject: [PATCH 14/15] Updated README Authentication section based on review comments --- README.md | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index a63c09e0..d20ed0a7 100644 --- a/README.md +++ b/README.md @@ -327,15 +327,15 @@ fmt.Print(status, status.ChannelId) ``` ### Authentication -- It is recommended to use `ABLY_KEY` at server side. Check [official ably auth documentation](https://ably.com/docs/auth) for more info. -- `ABLY_KEY` should not be exposed at client side where it can be used for malicious purposes. -- Server can use `ABLY_KEY` for initializing the `AblyRest` instance. + +It is recommended to only use an `ABLY_KEY` for authentication on server-side applications. For client-side applications, you should use token authentication to prevent your API key from being shared. See the [authentication documentation](https://ably.com/docs/auth) for more information. ```go restClient, err := ably.NewREST(ably.WithKey("API_KEY")) ``` -- Token requests are issued by your servers and signed using your private API key. -- This is the preferred method of authentication as no secrets are ever shared, and the token request can be issued to trusted clients without communicating with Ably. + +Token requests are issued by your servers and signed using your private API key as below. + ```go // e.g. Gin server endpoint router.GET("/token", getToken) @@ -351,23 +351,23 @@ func getToken(c *gin.Context) { ### Using the Token auth at client side -- You can provide either `WithAuthCallback` or `WithAuthURL` as a clientOption to request token. -- `WithAuthUrl` automatically decodes response based on response contentType. +You can provide either `WithAuthCallback` or `WithAuthURL` as a clientOption to request token. `WithAuthUrl` automatically decodes response based on the response contentType. `WithAuthCallback` may need manual decoding based on the response. ```go +// Return token of type ably.TokenRequest, ably.TokenDetails or ably.TokenString authCallback := ably.WithAuthCallback(func(ctx context.Context, tp ably.TokenParams) (ably.Tokener, error) { // HTTP client impl. to fetch token, you can pass tokenParams based on your requirement - // Return token of type ably.TokenDetails, ably.TokenRequest or ably.TokenString - // This may need manually decoding tokens based on the response. - token, err := requestTokenFrom(ctx, "/token"); + tokenReqJsonString, err := requestTokenFrom(ctx, "/token"); if err != nil { return nil, err // You can also log error here } - return token, err + var req ably.TokenRequest + err := json.Unmarshal(tokenReqJsonString, &req) + return req, err }) ``` -- If JWT token is returned by server +If JWT token is returned by server ```go authCallback := ably.WithAuthCallback(func(ctx context.Context, tp ably.TokenParams) (ably.Tokener, error) { jwtTokenString, err := requestTokenFrom(ctx, "/jwtToken"); @@ -377,7 +377,7 @@ authCallback := ably.WithAuthCallback(func(ctx context.Context, tp ably.TokenPar return ably.TokenString(jwtTokenString), err // return jwt token that starts with "ey" }) ``` -- Check [official token auth documentation](https://ably.com/docs/auth/token?lang=csharp) for more information. +Note - Check [official token auth documentation](https://ably.com/docs/auth/token?lang=go) for more information. ### Configure logging - By default, internal logger prints output to `stdout` with default logging level of `warning`. From ce1c6fbb53b1e6c885069337b6ffbc5a631c4387 Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Thu, 12 Sep 2024 11:57:03 +0530 Subject: [PATCH 15/15] Updated README as per review comments, added links to official doc --- README.md | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index d20ed0a7..1c895caf 100644 --- a/README.md +++ b/README.md @@ -328,13 +328,13 @@ fmt.Print(status, status.ChannelId) ### Authentication -It is recommended to only use an `ABLY_KEY` for authentication on server-side applications. For client-side applications, you should use token authentication to prevent your API key from being shared. See the [authentication documentation](https://ably.com/docs/auth) for more information. +Initialize `ably.NewREST` using `ABLY_KEY`. Check [Authentication Doc](https://ably.com/docs/auth) for more information types of auth and it's server/client-side usage. ```go restClient, err := ably.NewREST(ably.WithKey("API_KEY")) ``` -Token requests are issued by your servers and signed using your private API key as below. +Token requests are signed using provided `API_KEY` and issued by your servers. ```go // e.g. Gin server endpoint @@ -343,15 +343,13 @@ func getToken(c *gin.Context) { token, err := restClient.Auth.CreateTokenRequest(nil) c.IndentedJSON(http.StatusOK, token) } - ``` -- You can also return JWT string token signed using `ABLY_KEY` as per [official ably JWT doc](https://ably.com/tutorials/jwt-authentication). -- When using `WithAuthURL` clientOption at client side, for JWT response, contentType header should be set to `text/plain` or `application/jwt`. For `ably.TokenRequest`/ `ably.TokenDetails`, set it as `application/json`. +- When using `WithAuthURL` clientOption at client side, for [JWT token](https://ably.com/tutorials/jwt-authentication) response, contentType header should be set to `text/plain` or `application/jwt`. For `ably.TokenRequest`/ `ably.TokenDetails`, set it as `application/json`. ### Using the Token auth at client side -You can provide either `WithAuthCallback` or `WithAuthURL` as a clientOption to request token. `WithAuthUrl` automatically decodes response based on the response contentType. `WithAuthCallback` may need manual decoding based on the response. +`WithAuthUrl` clientOption automatically decodes response based on the response contentType, `WithAuthCallback` needs manual decoding based on the response. See [official token auth documentation](https://ably.com/docs/auth/token?lang=go) for more information. ```go // Return token of type ably.TokenRequest, ably.TokenDetails or ably.TokenString @@ -359,7 +357,7 @@ authCallback := ably.WithAuthCallback(func(ctx context.Context, tp ably.TokenPar // HTTP client impl. to fetch token, you can pass tokenParams based on your requirement tokenReqJsonString, err := requestTokenFrom(ctx, "/token"); if err != nil { - return nil, err // You can also log error here + return nil, err } var req ably.TokenRequest err := json.Unmarshal(tokenReqJsonString, &req) @@ -367,17 +365,16 @@ authCallback := ably.WithAuthCallback(func(ctx context.Context, tp ably.TokenPar }) ``` -If JWT token is returned by server +If [JWT token](https://ably.com/tutorials/jwt-authentication) is returned by server ```go authCallback := ably.WithAuthCallback(func(ctx context.Context, tp ably.TokenParams) (ably.Tokener, error) { - jwtTokenString, err := requestTokenFrom(ctx, "/jwtToken"); + jwtTokenString, err := requestTokenFrom(ctx, "/jwtToken"); // jwtTokenString starts with "ey" if err != nil { - return nil, err // You can also log error here + return nil, err } - return ably.TokenString(jwtTokenString), err // return jwt token that starts with "ey" + return ably.TokenString(jwtTokenString), err }) ``` -Note - Check [official token auth documentation](https://ably.com/docs/auth/token?lang=go) for more information. ### Configure logging - By default, internal logger prints output to `stdout` with default logging level of `warning`.