Skip to content

Commit 496c839

Browse files
authored
Merge pull request #669 from ably/fix/jwt-authentication
[ECO-4550] Fix JWT authentication
2 parents 2fd5ae2 + ce1c6fb commit 496c839

5 files changed

+330
-6
lines changed

README.md

+50-3
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,56 @@ if err != nil {
326326
fmt.Print(status, status.ChannelId)
327327
```
328328

329+
### Authentication
330+
331+
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.
332+
333+
```go
334+
restClient, err := ably.NewREST(ably.WithKey("API_KEY"))
335+
```
336+
337+
Token requests are signed using provided `API_KEY` and issued by your servers.
338+
339+
```go
340+
// e.g. Gin server endpoint
341+
router.GET("/token", getToken)
342+
func getToken(c *gin.Context) {
343+
token, err := restClient.Auth.CreateTokenRequest(nil)
344+
c.IndentedJSON(http.StatusOK, token)
345+
}
346+
```
347+
348+
- 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`.
349+
350+
### Using the Token auth at client side
351+
352+
`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.
353+
354+
```go
355+
// Return token of type ably.TokenRequest, ably.TokenDetails or ably.TokenString
356+
authCallback := ably.WithAuthCallback(func(ctx context.Context, tp ably.TokenParams) (ably.Tokener, error) {
357+
// HTTP client impl. to fetch token, you can pass tokenParams based on your requirement
358+
tokenReqJsonString, err := requestTokenFrom(ctx, "/token");
359+
if err != nil {
360+
return nil, err
361+
}
362+
var req ably.TokenRequest
363+
err := json.Unmarshal(tokenReqJsonString, &req)
364+
return req, err
365+
})
366+
367+
```
368+
If [JWT token](https://ably.com/tutorials/jwt-authentication) is returned by server
369+
```go
370+
authCallback := ably.WithAuthCallback(func(ctx context.Context, tp ably.TokenParams) (ably.Tokener, error) {
371+
jwtTokenString, err := requestTokenFrom(ctx, "/jwtToken"); // jwtTokenString starts with "ey"
372+
if err != nil {
373+
return nil, err
374+
}
375+
return ably.TokenString(jwtTokenString), err
376+
})
377+
```
378+
329379
### Configure logging
330380
- By default, internal logger prints output to `stdout` with default logging level of `warning`.
331381
- You need to create a custom Logger that implements `ably.Logger` interface.
@@ -442,9 +492,6 @@ As of release 1.2.0, the following are not implemented and will be covered in fu
442492

443493
- [Push notifications admin API](https://ably.com/docs/api/rest-sdk/push-admin) is not implemented.
444494

445-
- [JWT authentication](https://ably.com/docs/auth/token?lang=javascript#jwt) using `auth-url` is not implemented.
446-
See [jwt auth issue](https://github.com/ably/ably-go/issues/569) for more details.
447-
448495
### Realtime API
449496

450497
- Channel suspended state is partially implemented. See [suspended channel state](https://github.com/ably/ably-go/issues/568).

ably/auth.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -462,7 +462,7 @@ func (a *Auth) requestAuthURL(ctx context.Context, params *TokenParams, opts *au
462462
return nil, a.newError(40004, err)
463463
}
464464
switch typ {
465-
case "text/plain":
465+
case "text/plain", "application/jwt":
466466
token, err := io.ReadAll(resp.Body)
467467
if err != nil {
468468
return nil, a.newError(40000, err)

ably/auth_integration_test.go

+161
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,13 @@
44
package ably_test
55

66
import (
7+
"bytes"
78
"context"
89
"encoding/base64"
10+
"encoding/json"
911
"errors"
1012
"fmt"
13+
"io"
1114
"net/http"
1215
"net/url"
1316
"strings"
@@ -342,6 +345,164 @@ func TestAuth_RequestToken(t *testing.T) {
342345
}
343346
}
344347

348+
func TestAuth_JWT_Token_RSA8c(t *testing.T) {
349+
350+
t.Run("Get JWT from echo server", func(t *testing.T) {
351+
app := ablytest.MustSandbox(nil)
352+
defer safeclose(t, app)
353+
jwt, err := app.CreateJwt(3*time.Second, false)
354+
assert.NoError(t, err)
355+
assert.True(t, strings.HasPrefix(jwt, "ey"))
356+
357+
// JWT header assertions
358+
header := strings.Split(jwt, ".")[0]
359+
jwtToken, err := base64.RawStdEncoding.DecodeString(header)
360+
assert.NoError(t, err)
361+
var result map[string]interface{}
362+
err = json.Unmarshal(jwtToken, &result)
363+
assert.NoError(t, err)
364+
assert.Equal(t, "HS256", result["alg"])
365+
assert.Equal(t, "JWT", result["typ"])
366+
assert.Contains(t, result, "kid")
367+
assert.NoError(t, err)
368+
369+
// JWT payload assertions
370+
payload := strings.Split(jwt, ".")[1]
371+
jwtToken, err = base64.RawStdEncoding.DecodeString(payload)
372+
assert.NoError(t, err)
373+
err = json.Unmarshal(jwtToken, &result)
374+
assert.NoError(t, err)
375+
assert.Contains(t, result, "iat")
376+
assert.Contains(t, result, "exp")
377+
378+
// check expiry of 3 seconds
379+
assert.Equal(t, float64(3), result["exp"].(float64)-result["iat"].(float64))
380+
})
381+
382+
t.Run("Should be able to use it as a token", func(t *testing.T) {
383+
app := ablytest.MustSandbox(nil)
384+
defer safeclose(t, app)
385+
jwt, err := app.CreateJwt(3*time.Second, false)
386+
assert.NoError(t, err)
387+
assert.True(t, strings.HasPrefix(jwt, "ey"))
388+
389+
rec, optn := ablytest.NewHttpRecorder()
390+
rest, err := ably.NewREST(
391+
ably.WithToken(jwt),
392+
ably.WithEnvironment(app.Environment),
393+
optn[0],
394+
)
395+
assert.NoError(t, err, "rest()=%v", err)
396+
397+
_, err = rest.Stats().Pages(context.Background())
398+
assert.NoError(t, err, "Stats()=%v", err)
399+
400+
assert.Len(t, rec.Requests(), 1)
401+
assert.Len(t, rec.Responses(), 1)
402+
403+
statsRequest := rec.Request(0)
404+
assert.Equal(t, "/stats", statsRequest.URL.Path)
405+
encodedToken := base64.StdEncoding.EncodeToString([]byte(jwt))
406+
assert.Equal(t, "Bearer "+encodedToken, statsRequest.Header.Get("Authorization"))
407+
})
408+
409+
t.Run("RSA8g, RSA3d: Should be able to authenticate using authURL", func(t *testing.T) {
410+
app := ablytest.MustSandbox(nil)
411+
defer safeclose(t, app)
412+
413+
rec, optn := ablytest.NewHttpRecorder()
414+
rest, err := ably.NewREST(
415+
ably.WithAuthURL(ablytest.CREATE_JWT_URL),
416+
ably.WithAuthParams(app.GetJwtAuthParams(30*time.Second, false)),
417+
ably.WithEnvironment(app.Environment),
418+
optn[0],
419+
)
420+
assert.NoError(t, err, "rest()=%v", err)
421+
422+
_, err = rest.Stats().Pages(context.Background())
423+
assert.NoError(t, err, "Stats()=%v", err)
424+
425+
assert.Len(t, rec.Requests(), 2)
426+
assert.Len(t, rec.Responses(), 2)
427+
428+
// first request is jwt request
429+
jwtRequest := rec.Request(0).URL
430+
assert.Equal(t, ablytest.CREATE_JWT_URL, "https://"+jwtRequest.Host+jwtRequest.Path)
431+
// response is jwt token
432+
jwtResponse, err := io.ReadAll(rec.Response(0).Body)
433+
assert.NoError(t, err)
434+
assert.True(t, bytes.HasPrefix(jwtResponse, []byte("ey")))
435+
436+
// Second request is made to stats with given jwt token (base64 encoded)
437+
statsRequest := rec.Request(1)
438+
assert.Equal(t, "/stats", statsRequest.URL.Path)
439+
encodedToken := base64.StdEncoding.EncodeToString(jwtResponse)
440+
assert.Equal(t, "Bearer "+encodedToken, statsRequest.Header.Get("Authorization"))
441+
})
442+
443+
t.Run("RSA8g, RSA3d: Should be able to authenticate using authCallback", func(t *testing.T) {
444+
app := ablytest.MustSandbox(nil)
445+
defer safeclose(t, app)
446+
447+
jwtToken := ""
448+
authCallback := ably.WithAuthCallback(func(ctx context.Context, tp ably.TokenParams) (ably.Tokener, error) {
449+
jwtTokenString, err := app.CreateJwt(time.Second*30, false)
450+
jwtToken = jwtTokenString
451+
if err != nil {
452+
t.Fatalf("Error creating JWT: %v", err)
453+
return nil, err
454+
}
455+
return ably.TokenString(jwtTokenString), nil
456+
})
457+
458+
rec, optn := ablytest.NewHttpRecorder()
459+
rest, err := ably.NewREST(
460+
ably.WithEnvironment(app.Environment),
461+
authCallback,
462+
optn[0],
463+
)
464+
assert.NoError(t, err)
465+
466+
_, err = rest.Stats().Pages(context.Background())
467+
assert.NoError(t, err, "Stats()=%v", err)
468+
469+
assert.Len(t, rec.Requests(), 1)
470+
assert.Len(t, rec.Responses(), 1)
471+
472+
assert.True(t, strings.HasPrefix(jwtToken, "ey"))
473+
// Second request is made to stats with given jwt token (base64 encoded)
474+
statsRequest := rec.Request(0)
475+
assert.Equal(t, "/stats", statsRequest.URL.Path)
476+
encodedToken := base64.StdEncoding.EncodeToString([]byte(jwtToken))
477+
assert.Equal(t, "Bearer "+encodedToken, statsRequest.Header.Get("Authorization"))
478+
})
479+
480+
t.Run("RSA4e, RSA4b: Should return error when JWT is invalid", func(t *testing.T) {
481+
app := ablytest.MustSandbox(nil)
482+
defer safeclose(t, app)
483+
484+
rec, optn := ablytest.NewHttpRecorder()
485+
rest, err := ably.NewREST(
486+
ably.WithAuthURL(ablytest.CREATE_JWT_URL),
487+
ably.WithAuthParams(app.GetJwtAuthParams(30*time.Second, true)),
488+
ably.WithEnvironment(app.Environment),
489+
optn[0],
490+
)
491+
assert.NoError(t, err, "rest()=%v", err)
492+
493+
_, err = rest.Stats().Pages(context.Background())
494+
var errorInfo *ably.ErrorInfo
495+
assert.Error(t, err)
496+
assert.ErrorAs(t, err, &errorInfo)
497+
assert.Equal(t, 40144, int(errorInfo.Code))
498+
assert.Equal(t, 401, errorInfo.StatusCode)
499+
assert.Contains(t, err.Error(), "invalid JWT format")
500+
501+
assert.Len(t, rec.Requests(), 2)
502+
assert.Len(t, rec.Responses(), 2)
503+
})
504+
}
505+
345506
func TestAuth_ReuseClientID(t *testing.T) {
346507
opts := []ably.ClientOption{ably.WithUseTokenAuth(true)}
347508
app, client := ablytest.NewREST(opts...)

ably/realtime_conn_spec_integration_test.go

+57-2
Original file line numberDiff line numberDiff line change
@@ -3022,8 +3022,63 @@ func TestRealtimeConn_RTC8a_ExplicitAuthorizeWhileConnected(t *testing.T) {
30223022
ablytest.Instantly.Recv(t, nil, authorizeDone, t.Fatalf)
30233023
})
30243024

3025-
t.Run("RTC8a4: reauthorize with JWT token", func(t *testing.T) {
3026-
t.Skip("not implemented")
3025+
t.Run("RTC8a4, RSA3d: reauthorize with JWT token", func(t *testing.T) {
3026+
t.Parallel()
3027+
app := ablytest.MustSandbox(nil)
3028+
defer safeclose(t, app)
3029+
3030+
authCallbackTokens := []string{}
3031+
tokenExpiry := 3 * time.Second
3032+
// Returns token that expires after 3 seconds causing disconnect every 3 seconds
3033+
authCallback := func(ctx context.Context, tp ably.TokenParams) (ably.Tokener, error) {
3034+
jwtTokenString, err := app.CreateJwt(tokenExpiry, false)
3035+
if err != nil {
3036+
return nil, err
3037+
}
3038+
authCallbackTokens = append(authCallbackTokens, jwtTokenString)
3039+
return ably.TokenString(jwtTokenString), nil
3040+
}
3041+
3042+
realtimeMsgRecorder := NewMessageRecorder()
3043+
realtime, err := ably.NewRealtime(
3044+
ably.WithAutoConnect(false),
3045+
ably.WithEnvironment(ablytest.Environment),
3046+
ably.WithDial(realtimeMsgRecorder.Dial),
3047+
ably.WithAuthCallback(authCallback))
3048+
3049+
assert.NoError(t, err)
3050+
defer realtime.Close()
3051+
3052+
err = ablytest.Wait(ablytest.ConnWaiter(realtime, realtime.Connect, ably.ConnectionEventConnected), nil)
3053+
assert.NoError(t, err)
3054+
3055+
changes := make(ably.ConnStateChanges, 2)
3056+
off := realtime.Connection.OnAll(changes.Receive)
3057+
defer off()
3058+
var state ably.ConnectionStateChange
3059+
3060+
// Disconnects due to timeout
3061+
ablytest.Soon.Recv(t, &state, changes, t.Fatalf)
3062+
assert.Equal(t, ably.ConnectionEventDisconnected, state.Event)
3063+
// Reconnect again using new JWT token
3064+
ablytest.Soon.Recv(t, &state, changes, t.Fatalf)
3065+
assert.Equal(t, ably.ConnectionEventConnecting, state.Event)
3066+
ablytest.Soon.Recv(t, &state, changes, t.Fatalf)
3067+
assert.Equal(t, ably.ConnectionEventConnected, state.Event)
3068+
assert.Nil(t, state.Reason)
3069+
assert.Equal(t, ably.ConnectionStateConnected, realtime.Connection.State())
3070+
3071+
ablytest.Instantly.NoRecv(t, nil, changes, t.Fatalf)
3072+
3073+
// Make sure requested tokens are JWT tokens
3074+
assert.Len(t, authCallbackTokens, 2)
3075+
assert.True(t, strings.HasPrefix(authCallbackTokens[0], "ey"))
3076+
assert.True(t, strings.HasPrefix(authCallbackTokens[1], "ey"))
3077+
assertUnique(t, authCallbackTokens)
3078+
// 2 Dial attempts made
3079+
assert.Len(t, realtimeMsgRecorder.URLs(), 2)
3080+
assert.Equal(t, authCallbackTokens[0], realtimeMsgRecorder.URLs()[0].Query().Get("access_token"))
3081+
assert.Equal(t, authCallbackTokens[1], realtimeMsgRecorder.URLs()[1].Query().Get("access_token"))
30273082
})
30283083

30293084
t.Run("RTC8a2: Failed reauth moves connection to FAILED", func(t *testing.T) {

ablytest/sandbox.go

+61
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,67 @@ func (app *Sandbox) URL(paths ...string) string {
256256
return "https://" + app.Environment + "-rest.ably.io/" + path.Join(paths...)
257257
}
258258

259+
// Source code for the same => https://github.com/ably/echoserver/blob/main/app.js
260+
var CREATE_JWT_URL string = "https://echo.ably.io/createJWT"
261+
262+
// GetJwtAuthParams constructs the authentication parameters required for JWT creation.
263+
// Required when authUrl is chosen as a mode of auth
264+
//
265+
// Parameters:
266+
// - expiresIn: The duration until the JWT expires.
267+
// - invalid: A boolean flag indicating whether to use an invalid key secret.
268+
//
269+
// Returns: A url.Values object containing the authentication parameters.
270+
func (app *Sandbox) GetJwtAuthParams(expiresIn time.Duration, invalid bool) url.Values {
271+
key, secret := app.KeyParts()
272+
authParams := url.Values{}
273+
authParams.Add("environment", app.Environment)
274+
authParams.Add("returnType", "jwt")
275+
authParams.Add("keyName", key)
276+
if invalid {
277+
authParams.Add("keySecret", "invalid")
278+
} else {
279+
authParams.Add("keySecret", secret)
280+
}
281+
authParams.Add("expiresIn", fmt.Sprint(expiresIn.Seconds()))
282+
return authParams
283+
}
284+
285+
// CreateJwt generates a JWT with the specified expiration time.
286+
//
287+
// Parameters:
288+
// - expiresIn: The duration until the JWT expires.
289+
// - invalid: A boolean flag indicating whether to use an invalid key secret.
290+
//
291+
// Returns:
292+
// - A string containing the generated JWT.
293+
// - An error if the JWT creation fails.
294+
func (app *Sandbox) CreateJwt(expiresIn time.Duration, invalid bool) (string, error) {
295+
u, err := url.Parse(CREATE_JWT_URL)
296+
if err != nil {
297+
return "", err
298+
}
299+
u.RawQuery = app.GetJwtAuthParams(expiresIn, invalid).Encode()
300+
req, err := http.NewRequest(http.MethodGet, u.String(), nil)
301+
if err != nil {
302+
return "", fmt.Errorf("client: could not create request: %s", err)
303+
}
304+
res, err := app.client.Do(req)
305+
if err != nil {
306+
res.Body.Close()
307+
return "", fmt.Errorf("client: error making http request: %s", err)
308+
}
309+
defer res.Body.Close()
310+
resBody, err := io.ReadAll(res.Body)
311+
if err != nil {
312+
return "", fmt.Errorf("client: could not read response body: %s", err)
313+
}
314+
if res.StatusCode != 200 {
315+
return "", fmt.Errorf("non-success response received: %v:%s", res.StatusCode, resBody)
316+
}
317+
return string(resBody), nil
318+
}
319+
259320
func NewHTTPClient() *http.Client {
260321
const timeout = time.Minute
261322
return &http.Client{

0 commit comments

Comments
 (0)