Skip to content

Commit 4bd522c

Browse files
committed
fix(jwt): kid is part of header
1 parent 48ce196 commit 4bd522c

File tree

6 files changed

+130
-48
lines changed

6 files changed

+130
-48
lines changed

cli/commands/generate-jwt-keypair.ts

Lines changed: 2 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,7 @@ import {
77
type Settings,
88
} from '../../settings/jwt.js'
99
import { STACK_NAME } from '../../cdk/stackConfig.js'
10-
import run from '@bifravst/run'
11-
import fs from 'node:fs/promises'
12-
import path from 'node:path'
13-
import os from 'node:os'
10+
import { generateJWTKeyPair } from '../../jwt/generateJWTKeyPair.js'
1411

1512
export const generateJWTKeypairCommand = ({
1613
ssm,
@@ -41,30 +38,7 @@ export const generateJWTKeypairCommand = ({
4138
return
4239
}
4340

44-
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'jwt-'))
45-
const privateKeyFile = path.join(tempDir, 'private.pem')
46-
const publicKeyFile = path.join(tempDir, 'public.pem')
47-
48-
await run({
49-
command: 'openssl',
50-
args: [
51-
'ecparam',
52-
'-out',
53-
privateKeyFile,
54-
'-name',
55-
'secp521r1',
56-
'-genkey',
57-
],
58-
})
59-
60-
await run({
61-
command: 'openssl',
62-
args: ['ec', '-out', publicKeyFile, '-in', privateKeyFile, '-pubout'],
63-
})
64-
65-
const privateKey = await fs.readFile(privateKeyFile, 'utf-8')
66-
const publicKey = await fs.readFile(publicKeyFile, 'utf-8')
67-
const keyId = crypto.randomUUID()
41+
const { privateKey, publicKey, keyId } = await generateJWTKeyPair()
6842

6943
const put = putSetting({
7044
ssm,

feature-runner/steps/jwt.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,20 +32,28 @@ const jwtVerify = ({
3232
const expected = JSON.parse(codeBlockOrThrow(step).code)
3333
const token = context[storageName]
3434
progress(token)
35-
const decoded = jwt.verify(token, publicKey, {
35+
const decoded = jwt.decode(token, { complete: true })
36+
37+
jwt.verify(token, publicKey, {
3638
audience,
3739
}) as jwt.JwtPayload
3840
progress(JSON.stringify(decoded, null, 2))
3941

40-
check(decoded).is(
42+
check(decoded?.payload).is(
4143
objectMatching({
4244
...expected,
4345
aud: audience,
44-
kid: keyId,
4546
iat: closeTo(Date.now() / 1000, 10),
4647
exp: closeTo(Date.now() / 1000 + 60 * 60, 10),
4748
}),
4849
)
50+
51+
check(decoded?.header).is(
52+
objectMatching({
53+
alg: 'ES512',
54+
kid: keyId,
55+
}),
56+
)
4957
},
5058
)
5159

jwt/deviceJWT.spec.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { it, describe } from 'node:test'
2+
import { generateJWTKeyPair } from './generateJWTKeyPair.js'
3+
import { deviceJWT } from './deviceJWT.js'
4+
import { randomWords } from '@bifravst/random-words'
5+
import { ModelID } from '@hello.nrfcloud.com/proto-map/models'
6+
import jwt from 'jsonwebtoken'
7+
import assert from 'node:assert/strict'
8+
9+
void describe('deviceJWT()', () => {
10+
void it('should return a JWT', async () => {
11+
const { privateKey, publicKey, keyId } = await generateJWTKeyPair()
12+
13+
const deviceId = randomWords({ numWords: 3 }).join('-')
14+
const id = crypto.randomUUID()
15+
16+
const token = deviceJWT(
17+
{
18+
deviceId,
19+
id,
20+
model: ModelID.Thingy91x,
21+
},
22+
{
23+
privateKey,
24+
keyId,
25+
},
26+
)
27+
28+
const decoded = jwt.decode(token, { complete: true })
29+
jwt.verify(token, publicKey, {
30+
audience: 'hello.nrfcloud.com',
31+
}) as jwt.JwtPayload
32+
33+
const header = decoded?.header as jwt.JwtHeader
34+
const payload = decoded?.payload as jwt.JwtPayload
35+
36+
assert.equal(header.kid, keyId)
37+
assert.equal(header.alg, 'ES512')
38+
assert.equal(payload.aud, 'hello.nrfcloud.com')
39+
assert.equal(payload.deviceId, deviceId)
40+
assert.equal(payload.id, id)
41+
assert.equal(payload.model, 'thingy91x')
42+
assert.equal(
43+
(payload.iat ?? 0) >= Math.floor(Date.now() / 1000),
44+
true,
45+
'Should not be in the past',
46+
)
47+
assert.equal(
48+
payload.exp,
49+
(payload.iat ?? 0) + 3600,
50+
'Should be valid for an hour',
51+
)
52+
})
53+
})

jwt/deviceJWT.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import type { PublicDeviceRecord } from '../devices/publicDevicesRepo.js'
2+
3+
import jwt from 'jsonwebtoken'
4+
5+
export const deviceJWT = (
6+
device: Pick<PublicDeviceRecord, 'id' | 'deviceId' | 'model'>,
7+
{
8+
privateKey,
9+
keyId,
10+
}: {
11+
privateKey: string
12+
keyId: string
13+
},
14+
): string =>
15+
jwt.sign(
16+
{
17+
id: device.id,
18+
deviceId: device.deviceId,
19+
model: device.model,
20+
},
21+
privateKey,
22+
{
23+
algorithm: 'ES512',
24+
expiresIn: '1h',
25+
audience: 'hello.nrfcloud.com',
26+
keyid: keyId,
27+
},
28+
)

jwt/generateJWTKeyPair.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import run from '@bifravst/run'
2+
import fs from 'node:fs/promises'
3+
import path from 'node:path'
4+
import os from 'node:os'
5+
6+
export const generateJWTKeyPair = async (): Promise<{
7+
privateKey: string
8+
publicKey: string
9+
keyId: string
10+
}> => {
11+
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'jwt-'))
12+
const privateKeyFile = path.join(tempDir, 'private.pem')
13+
const publicKeyFile = path.join(tempDir, 'public.pem')
14+
15+
await run({
16+
command: 'openssl',
17+
args: ['ecparam', '-out', privateKeyFile, '-name', 'secp521r1', '-genkey'],
18+
})
19+
20+
await run({
21+
command: 'openssl',
22+
args: ['ec', '-out', publicKeyFile, '-in', privateKeyFile, '-pubout'],
23+
})
24+
25+
const [privateKey, publicKey] = await Promise.all([
26+
fs.readFile(privateKeyFile, 'utf-8'),
27+
fs.readFile(publicKeyFile, 'utf-8'),
28+
])
29+
const keyId = crypto.randomUUID()
30+
31+
await Promise.all([fs.rm(privateKeyFile), fs.rm(publicKeyFile)])
32+
33+
return { privateKey, publicKey, keyId }
34+
}

lambda/deviceJwt.ts

Lines changed: 2 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import type {
1616
APIGatewayProxyEventV2,
1717
APIGatewayProxyResultV2,
1818
} from 'aws-lambda'
19-
import jwt from 'jsonwebtoken'
19+
import { deviceJWT } from '../jwt/deviceJWT.js'
2020
import { publicDevicesRepo } from '../devices/publicDevicesRepo.js'
2121
import { getSettings } from '../settings/jwt.js'
2222

@@ -59,8 +59,6 @@ const h = async (
5959

6060
const maybeSharedDevice = await devicesRepo.getById(id)
6161

62-
console.log(JSON.stringify(maybeSharedDevice))
63-
6462
if ('error' in maybeSharedDevice) {
6563
return aProblem({
6664
title: `Device with id ${maybeValidQuery.value.id} not shared: ${maybeSharedDevice.error}`,
@@ -75,20 +73,7 @@ const h = async (
7573
id: maybeSharedDevice.device.id,
7674
deviceId: maybeSharedDevice.device.deviceId,
7775
model: maybeSharedDevice.device.model,
78-
jwt: jwt.sign(
79-
{
80-
id: maybeSharedDevice.device.id,
81-
deviceId: maybeSharedDevice.device.deviceId,
82-
model: maybeSharedDevice.device.model,
83-
},
84-
jwtSettings.privateKey,
85-
{
86-
algorithm: 'ES512',
87-
expiresIn: '1h',
88-
audience: 'hello.nrfcloud.com',
89-
keyid: jwtSettings.keyId,
90-
},
91-
),
76+
jwt: deviceJWT(maybeSharedDevice.device, jwtSettings),
9277
},
9378
60 * 60 * 1000,
9479
)

0 commit comments

Comments
 (0)