Skip to content

Commit eaa5333

Browse files
author
Kevin COMBRIAT
committed
refactor(claims): Flatten unregistered claims
1 parent bb7bc83 commit eaa5333

File tree

6 files changed

+117
-81
lines changed

6 files changed

+117
-81
lines changed

README.md

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -26,37 +26,45 @@ sign
2626

2727
By default, some values will be set for you: `alg` will be `HS256`, `typ` equals `JWT`, and the `iat` field will be set to the creation timestamp. You _can_ override any for the above by providing the value explicitely.
2828

29-
You can also provide an `unregistered` claim, that will contain literally any [encodable](https://pursuit.purescript.org/packages/purescript-foreign-generic/10.0.0/docs/Foreign.Generic.Class#t:Encode) data:
29+
You can also provide an `unregisteredClaims` record, each values of that record must be [encodable](https://pursuit.purescript.org/packages/purescript-foreign-generic/10.0.0/docs/Foreign.Generic.Class#t:Encode):
3030

3131
```purs
3232
sign
3333
(Secret "my-super-secret-key")
3434
defaultHeaders
35-
(defaultClaims { unregistered = unregisteredClaim "Foo" } )
35+
(defaultClaims { unregisteredClaims = Just { foo: "bar" } )
3636
```
3737

3838
### Decode
3939

40-
If decode succeeds, it will return a `Token Unverified` you can read the headers and claims from it:
40+
If decode succeeds, it will return a `Token r Unverified` where `r` is the row type of the `unregisteredClaims` record. You can read the headers and claims as follow:
4141

4242
```purs
4343
decodedHeaders :: String -> Maybe JOSEHeaders
44-
decodedHeaders token = decode token >>= hush <<< headers
44+
decodedHeaders token = hush $ decode' token <#> _.headers
4545
46-
decodedClaims :: String -> Maybe Claims
47-
decodedClaims token = decode token >>= hush <<< claims
46+
decodedClaims :: String -> Maybe (Claims ())
47+
decodedClaims token = hush $ decode' token <#> _.claims
48+
49+
decodedClaims' :: String -> Maybe (Claims ( foo :: String ))
50+
decodedClaims' token = hush $ decode token <#> _.claims
4851
```
4952

53+
Notice that when decoding claims with some explicit unregistered claims, said claims must be of the expected type at runtime.
54+
5055
### Verify
5156

52-
If verify succeeds, it will return a `Token Verified` you can read the headers and claims from it:
57+
If verify succeeds, it will return a `Token Verified` you can read the headers and claims this way:
5358

5459
```purs
5560
verifiedHeaders :: String -> Maybe JOSEHeaders
56-
verifiedHeaders token = verify (Secret "my-super-secret-key") token >>= hush <<< headers
61+
verifiedHeaders token = hush $ verify' (Secret "my-super-secret-key") token <#> _.headers
62+
63+
verifiedClaims :: String -> Maybe (Claims ())
64+
verifiedClaims token = hush $ verify' (Secret "my-super-secret-key") token <#> _.claims
5765
58-
verifiedClaims :: String -> Maybe Claims
59-
verifiedClaims token = verify (Secret "my-super-secret-key") token >>= hush <<< claims
66+
verifiedClaims' :: String -> Maybe (Claims ( foo :: String ))
67+
verifiedClaims' token = hush $ verify (Secret "my-super-secret-key") token <#> _.claims
6068
```
6169

6270
## Documentation

src/Node/Jwt.js

Lines changed: 48 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,65 @@
1-
var jwt = require("jsonwebtoken");
1+
const jwt = require("jsonwebtoken");
22

3-
exports._decode = function decode(just, nothing, token) {
3+
const registeredClaimsKeys = ["iss", "sub", "aud", "exp", "nbf", "iat", "jti"];
4+
5+
// TODO: Remove this when https://github.com/erikd/language-javascript/pull/118 is merged
6+
const partitionClaims = (claims) =>
7+
Object.entries(claims).reduce(
8+
([registered, unregistered], [key, value]) => {
9+
const claim = { [key]: value };
10+
11+
return registeredClaimsKeys.includes(key)
12+
? [Object.assign({}, registered, claim), unregistered]
13+
: [registered, Object.assign({}, unregistered || {}, claim)];
14+
},
15+
[{}, undefined]
16+
);
17+
18+
const normalizeClaims = ({ header, payload, signature }, just, nothing) => {
419
try {
5-
const decodedToken = jwt.decode(token, { complete: true, json: false });
20+
const [registeredClaims, unregisteredClaims] = partitionClaims(payload);
621

7-
return decodedToken ? just(decodedToken) : nothing;
8-
} catch (error) {
22+
return just({
23+
header,
24+
payload: Object.assign({}, registeredClaims, { unregisteredClaims }),
25+
signature,
26+
});
27+
} catch (_) {
928
return nothing;
1029
}
1130
};
1231

13-
exports._verify = function verify(just, nothing, secret, token) {
32+
exports._decode = (just, nothing, token) => {
1433
try {
15-
const verifiedToken = jwt.verify(token, secret, {
16-
complete: true,
17-
json: false,
18-
});
34+
return normalizeClaims(
35+
jwt.decode(token, { complete: true, json: false }),
36+
just,
37+
nothing
38+
);
39+
} catch (_) {
40+
return nothing;
41+
}
42+
};
1943

20-
return verifiedToken ? just(verifiedToken) : nothing;
21-
} catch (error) {
44+
exports._verify = (just, nothing, token, secret) => {
45+
try {
46+
return normalizeClaims(
47+
jwt.verify(token, secret, { complete: true, json: false }),
48+
just,
49+
nothing
50+
);
51+
} catch (_) {
2252
return nothing;
2353
}
2454
};
2555

26-
exports._sign = function sign(payload, secret, options) {
56+
exports._sign = (payload, unregisteredClaims, secret, options) => {
57+
const fullPayload = unregisteredClaims
58+
? Object.assign({}, payload, unregisteredClaims)
59+
: payload;
60+
2761
return new Promise(function (resolve, reject) {
28-
jwt.sign(payload, secret, options, function (error, token) {
62+
jwt.sign(fullPayload, secret, options, function (error, token) {
2963
if (error) {
3064
return reject(error);
3165
}

src/Node/Jwt.purs

Lines changed: 34 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
module Node.Jwt
22
( module Types
33
, decode
4+
, decode'
45
, sign
56
, verify
7+
, verify'
68
) where
79

810
import Types
@@ -17,10 +19,10 @@ import Data.Newtype (unwrap)
1719
import Data.Options (options, (:=))
1820
import Data.Traversable (traverse)
1921
import Effect.Aff (Aff)
20-
import Effect.Uncurried (EffectFn3, runEffectFn3)
22+
import Effect.Uncurried (EffectFn4, runEffectFn4)
2123
import Foreign (ForeignError, readNullOrUndefined, readString, renderForeignError)
2224
import Foreign.Generic (F, Foreign)
23-
import Foreign.Generic (decode) as Generic
25+
import Foreign.Generic (decode, encode) as Generic
2426
import Foreign.Index ((!))
2527
import Options as Options
2628
import Prelude (bind, map, pure, ($), (<$>), (<*>), (<>), (>>=))
@@ -41,8 +43,8 @@ claims token =
4143
iss <- token ! "payload" ! "iss" >>= readNullOrUndefined >>= traverse readString
4244
sub <- token ! "payload" ! "sub" >>= readNullOrUndefined >>= traverse readString
4345
jti <- token ! "payload" ! "jti" >>= readNullOrUndefined >>= traverse readString
44-
(unregistered :: Maybe (Record r)) <- token ! "payload" ! "unregistered" >>= readNullOrUndefined >>= traverse Generic.decode
45-
pure { iat, nbf, exp, aud: unwrap <$> aud, iss, sub, jti, unregistered }
46+
unregisteredClaims <- token ! "payload" ! "unregisteredClaims" >>= readNullOrUndefined >>= traverse Generic.decode
47+
pure { iat, nbf, exp, aud: unwrap <$> aud, iss, sub, jti, unregisteredClaims }
4648

4749
-- Extract JWT headers from any foreign value
4850
headers :: Foreign -> Either (NonEmptyList ForeignError) JOSEHeaders
@@ -54,42 +56,38 @@ headers token =
5456
cty <- token ! "header" ! "cty" >>= readNullOrUndefined >>= traverse Generic.decode
5557
pure { alg, typ, kid, cty }
5658

57-
foreign import _sign :: EffectFn3 Foreign String Foreign (Promise String)
59+
foreign import _sign :: EffectFn4 Foreign Foreign String Foreign (Promise String)
5860

5961
sign :: forall r l. Encodable r l => Secret -> JOSEHeaders -> Claims r -> Aff String
60-
sign (Secret secret) { typ, cty, alg, kid } { iss, sub, aud, exp, nbf, iat, jti, unregistered } =
62+
sign (Secret secret) { typ, cty, alg, kid } { iss, sub, aud, exp, nbf, iat, jti, unregisteredClaims } =
6163
toAffE
62-
$ runEffectFn3 _sign payloadOptions secret
63-
$ signOptions
64+
$ runEffectFn4 _sign payloadOptions (Generic.encode unregisteredClaims) secret signOptions
6465
where
6566
payloadOptions :: Foreign
6667
payloadOptions =
6768
options
68-
( (Options.iat := iat)
69-
<> (Options.nbf := nbf)
70-
<> (Options.exp := exp)
71-
<> (Options.unregistered := unregistered)
72-
)
69+
$ (Options.iat := iat)
70+
<> (Options.nbf := nbf)
71+
<> (Options.exp := exp)
72+
73+
signHeaderOptions :: Foreign
74+
signHeaderOptions =
75+
options
76+
$ (Options.typ := typ)
77+
<> (Options.cty := cty)
78+
<> (Options.alg := alg)
79+
<> (Options.kid := kid)
7380

7481
signOptions :: Foreign
7582
signOptions =
7683
options
77-
( (Options.algorithm := alg)
78-
<> (Options.audience := aud)
79-
<> (Options.issuer := iss)
80-
<> (Options.jwtid := jti)
81-
<> (Options.subject := sub)
82-
<> (Options.keyid := kid)
83-
<> ( Options.header
84-
:= ( options
85-
( (Options.typ := typ)
86-
<> (Options.cty := cty)
87-
<> (Options.alg := alg)
88-
<> (Options.kid := kid)
89-
)
90-
)
91-
)
92-
)
84+
$ (Options.algorithm := alg)
85+
<> (Options.audience := aud)
86+
<> (Options.issuer := iss)
87+
<> (Options.jwtid := jti)
88+
<> (Options.subject := sub)
89+
<> (Options.keyid := kid)
90+
<> (Options.header := signHeaderOptions)
9391

9492
-- Utility function used to automatically convert any foreign value into a token
9593
foreignToToken :: forall r l s. Decodable r l => Foreign -> Either (NonEmptyList String) (Token r s)
@@ -103,7 +101,13 @@ foreign import _decode :: Fn3 (Foreign -> Maybe Foreign) (Maybe Foreign) String
103101
decode :: forall r l. Decodable r l => String -> Either (NonEmptyList String) (Token r Unverified)
104102
decode s = (note (singleton "Couldn't decode token") $ runFn3 _decode Just Nothing s) >>= foreignToToken
105103

104+
decode' :: String -> Either (NonEmptyList String) (Token () Unverified)
105+
decode' = decode
106+
106107
foreign import _verify :: Fn4 (Foreign -> Maybe Foreign) (Maybe Foreign) String String (Maybe Foreign)
107108

108109
verify :: forall r l. Decodable r l => Secret -> String -> Either (NonEmptyList String) (Token r Verified)
109-
verify (Secret secret) s = (note (singleton "Couldn't verify token") $ runFn4 _verify Just Nothing secret s) >>= foreignToToken
110+
verify (Secret secret) s = (note (singleton "Couldn't verify token") $ runFn4 _verify Just Nothing s secret) >>= foreignToToken
111+
112+
verify' :: Secret -> String -> Either (NonEmptyList String) (Token () Unverified)
113+
verify' = verify

src/Node/Options.purs

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import Data.Options (Option, opt, optional)
77
import Foreign.Generic (Foreign, encode)
88
import Prelude (($), (<<<))
99
import Types (Algorithm, EitherWrapper(..), NumericDate, Typ)
10-
import GenericRecord (class Encodable)
1110

1211
foreign import data SignOptions :: Type
1312

@@ -56,6 +55,3 @@ nbf = optional $ cmap encode $ opt "nbf"
5655

5756
exp :: Option PayloadOptions (Maybe NumericDate)
5857
exp = optional $ cmap encode $ opt "exp"
59-
60-
unregistered :: forall r l. Encodable r l => Option PayloadOptions (Maybe (Record r))
61-
unregistered = optional $ cmap encode $ opt "unregistered"

src/Node/Types.purs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ type Claims r
117117
, nbf :: Maybe NumericDate
118118
, iat :: Maybe NumericDate
119119
, jti :: Maybe String
120-
, unregistered :: Maybe (Record r)
120+
, unregisteredClaims :: Maybe (Record r)
121121
}
122122

123123
data Verified
@@ -138,7 +138,7 @@ defaultClaims =
138138
, nbf: Nothing
139139
, iat: Nothing
140140
, jti: Nothing
141-
, unregistered: Nothing
141+
, unregisteredClaims: Nothing
142142
}
143143

144144
newtype Secret

test/Main.purs

Lines changed: 15 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import Effect (Effect)
1111
import Effect.Aff (Milliseconds(..), launchAff_)
1212
import Effect.Class (class MonadEffect, liftEffect)
1313
import Effect.Now (now)
14-
import Node.Jwt (Algorithm(..), NumericDate(..), Secret(..), Token, Typ(..), Unverified, Verified, Claims, decode, defaultClaims, defaultHeaders, sign, verify)
14+
import Node.Jwt (Algorithm(..), Claims, NumericDate(..), Secret(..), Typ(..), decode, decode', defaultClaims, defaultHeaders, sign, verify')
1515
import Prelude (Unit, bind, bottom, discard, negate, (#), ($), (&&), (/=), (<), (<#>), (<$>), (<<<), (>), (>>=), (>>>))
1616
import Prim.Row (class Lacks)
1717
import Record (delete)
@@ -20,8 +20,8 @@ import Test.Spec.Assertions (shouldEqual, shouldSatisfy)
2020
import Test.Spec.Reporter (consoleReporter)
2121
import Test.Spec.Runner (runSpec)
2222

23-
cleanClaims :: forall r a. Lacks "unregistered" r => { unregistered :: a | r } -> { | r }
24-
cleanClaims = delete (SProxy :: SProxy "unregistered")
23+
cleanClaims :: forall r a. Lacks "unregisteredClaims" r => { unregisteredClaims :: a | r } -> { | r }
24+
cleanClaims = delete (SProxy :: SProxy "unregisteredClaims")
2525

2626
getTimestamp :: forall m. MonadEffect m => m DateTime
2727
getTimestamp = liftEffect $ modifyTime (setMillisecond bottom) <<< toDateTime <$> now
@@ -41,7 +41,7 @@ main =
4141
it "signs a token with custom unregistered claims" do
4242
token <-
4343
sign (Secret "whatever") defaultHeaders
44-
$ defaultClaims { unregistered = Just { foo: "bar" } }
44+
$ defaultClaims { unregisteredClaims = Just { "https://my-domain.com/foo": "bar" } }
4545
token `shouldSatisfy` (/=) ""
4646
describe "decode" do
4747
it "decodes a token with default headers, and default claims" do
@@ -50,18 +50,17 @@ main =
5050
claims' = defaultClaims { iat = Just $ wrap timestamp }
5151
token <- sign (Secret "whatever") defaultHeaders claims'
5252
let
53-
decodedToken :: Maybe (Token () Unverified)
54-
decodedToken = hush $ decode token
53+
decodedToken = hush $ decode' token
5554
(decodedToken <#> _.headers) `shouldEqual` Just defaultHeaders
5655
(decodedToken <#> _.claims <#> cleanClaims) `shouldEqual` Just (cleanClaims claims')
5756
it "decodes a token with custom unregistered claims" do
5857
token <-
5958
sign (Secret "whatever") defaultHeaders
60-
$ defaultClaims { unregistered = Just { foo: "bar" } }
59+
$ defaultClaims { unregisteredClaims = Just { "https://my-domain.com/foo": "bar" } }
6160
let
6261
decodedToken = hush $ decode token
6362
(decodedToken <#> _.headers) `shouldEqual` Just defaultHeaders
64-
(decodedToken >>= _.claims >>> _.unregistered) `shouldEqual` Just { foo: "bar" }
63+
(decodedToken >>= _.claims >>> _.unregisteredClaims) `shouldEqual` Just { "https://my-domain.com/foo": "bar" }
6564
it "decodes a token with default headers, and default claims, with numeric date" do
6665
timestamp <- getTimestamp
6766
let
@@ -76,12 +75,11 @@ main =
7675
, jti: Just "an id"
7776
, nbf: wrap <<< toDateTime <$> (instant $ Milliseconds 1000.0)
7877
, sub: Just "subject!"
79-
, unregistered: Nothing
78+
, unregisteredClaims: Nothing
8079
}
8180
token <- sign (Secret "whatever") customHeaders customClaims
8281
let
83-
decodedToken :: Maybe (Token () Unverified)
84-
decodedToken = hush $ decode token
82+
decodedToken = hush $ decode' token
8583
(decodedToken <#> _.claims >>= _.exp # isJust) `shouldEqual` true
8684
(decodedToken <#> _.claims >>= _.nbf # isJust) `shouldEqual` true
8785
(decodedToken <#> _.headers) `shouldEqual` Just customHeaders
@@ -100,12 +98,11 @@ main =
10098
, jti: Just "an id"
10199
, nbf: Nothing
102100
, sub: Just "subject!"
103-
, unregistered: Nothing
101+
, unregisteredClaims: Nothing
104102
}
105103
token <- sign (Secret "whatever") customHeaders customClaims
106104
let
107-
decodedToken :: Maybe (Token () Unverified)
108-
decodedToken = hush $ decode token
105+
decodedToken = hush $ decode' token
109106
(decodedToken <#> _.headers) `shouldEqual` Just customHeaders
110107
let
111108
receivedClaims = decodedToken <#> _.claims <#> cleanClaims
@@ -121,15 +118,13 @@ main =
121118
claims' = defaultClaims { iat = Just $ wrap timestamp }
122119
token <- sign (Secret "whatever") defaultHeaders claims'
123120
let
124-
verifiedToken :: Maybe (Token () Verified)
125-
verifiedToken = hush $ verify (Secret "whatever") token
121+
verifiedToken = hush $ verify' (Secret "whatever") token
126122
(verifiedToken <#> _.headers) `shouldEqual` Just defaultHeaders
127123
(verifiedToken <#> _.claims <#> cleanClaims) `shouldEqual` Just (cleanClaims claims')
128124
it "doesn't verify a token when the secrets differ" do
129125
token <- sign (Secret "whatever") defaultHeaders defaultClaims
130126
let
131-
verifiedToken :: Maybe (Token () Verified)
132-
verifiedToken = hush $ verify (Secret "whatever!") token
127+
verifiedToken = hush $ verify' (Secret "whatever!") token
133128
verifiedToken `shouldEqual` Nothing
134129
(verifiedToken <#> _.headers) `shouldEqual` Nothing
135130
(verifiedToken <#> _.claims <#> cleanClaims) `shouldEqual` Nothing
@@ -147,12 +142,11 @@ main =
147142
, jti: Just "an id"
148143
, nbf: Nothing
149144
, sub: Just "subject!"
150-
, unregistered: Nothing
145+
, unregisteredClaims: Nothing
151146
}
152147
token <- sign (Secret "whatever") customHeaders customClaims
153148
let
154-
verifiedToken :: Maybe (Token () Verified)
155-
verifiedToken = hush $ verify (Secret "whatever") token
149+
verifiedToken = hush $ verify' (Secret "whatever") token
156150
(verifiedToken <#> _.headers) `shouldEqual` Just customHeaders
157151
let
158152
receivedClaims = verifiedToken <#> _.claims <#> cleanClaims

0 commit comments

Comments
 (0)