Skip to content

Commit 264bd38

Browse files
committed
feat: generate JWT for authenticating history queries
Users request device history from the hello.nrfcloud.com backend API, and since the hello.nrfcloud.com/map backend "knows" about shared devices, it creates a JWT that will be passed with history requests to the hello.nrfcloud.com backend which then uses the hello.nrfcloud.com/map backend public key to verify the authenticity of the request.
1 parent e314b90 commit 264bd38

27 files changed

+663
-22
lines changed

.github/workflows/test-and-release.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,8 @@ jobs:
207207
./cli.sh configure-nrfcloud-account apiKey apiKey_Nordic
208208
./cli.sh configure-hello apiEndpoint ${{ env.HTTP_API_MOCK_API_URL }}hello-api/
209209
210+
- run: ./cli.sh generate-jwt-keypair
211+
210212
- name: Deploy solution stack
211213
env:
212214
IS_TEST: 1

README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,21 @@ Provide your nRF Cloud API key:
3737
./cli.sh configure-nrfcloud-account apiKey <API key>
3838
```
3939

40+
#### Keypair for signing history request
41+
42+
The history is persisted in the
43+
[`backend`](https://github.com/hello-nrfcloud/backend), and the frontend
44+
requests device history using the same API as the
45+
[web application](https://github.com/hello-nrfcloud/web), however since public
46+
devices don't have a fingerprint, a JWT is created for public devices by the map
47+
backend, which is then used by the backend to authenticate history requests for
48+
devices. The following command installs a JWT keypair, and the public key is
49+
published at <https://api.nordicsemi.world/.well-known/jwks.json>.
50+
51+
```bash
52+
./cli.sh generate-jwt-keypair
53+
```
54+
4055
### Build the docker images
4156

4257
Some of the feature are run from docker containers, ensure they have been built

cdk/BackendStack.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
CustomDomain,
2323
type CustomDomainDetails,
2424
} from './resources/api/CustomDomain.js'
25+
import { JWKS } from './resources/JWKS.js'
2526

2627
/**
2728
* Provides resources for the backend serving data to hello.nrfcloud.com/map
@@ -34,6 +35,7 @@ export class BackendStack extends Stack {
3435
apiDomain,
3536
layer,
3637
cdkLayer,
38+
jwtLayer,
3739
lambdaSources,
3840
openSSLLambdaContainerTag,
3941
repository,
@@ -43,6 +45,7 @@ export class BackendStack extends Stack {
4345
apiDomain?: CustomDomainDetails
4446
layer: PackedLayer
4547
cdkLayer: PackedLayer
48+
jwtLayer: PackedLayer
4649
lambdaSources: BackendLambdas
4750
openSSLLambdaContainerTag: string
4851
repository: {
@@ -65,6 +68,17 @@ export class BackendStack extends Stack {
6568
compatibleRuntimes: [Lambda.Runtime.NODEJS_20_X],
6669
})
6770

71+
const jwtLayerVersion = new Lambda.LayerVersion(this, 'jwtLayer', {
72+
layerVersionName: `${Stack.of(this).stackName}-jwtLayer`,
73+
code: new LambdaSource(this, {
74+
id: 'jwtLayer',
75+
zipFile: jwtLayer.layerZipFile,
76+
hash: jwtLayer.hash,
77+
}).code,
78+
compatibleArchitectures: [Lambda.Architecture.ARM_64],
79+
compatibleRuntimes: [Lambda.Runtime.NODEJS_20_X],
80+
})
81+
6882
const publicDevices = new PublicDevices(this)
6983
new CfnOutput(this, 'publicDevicesTableName', {
7084
exportName: `${this.stackName}:publicDevicesTableName`,
@@ -115,13 +129,15 @@ export class BackendStack extends Stack {
115129
const shareAPI = new ShareAPI(this, {
116130
domain,
117131
baseLayer,
132+
jwtLayer: jwtLayerVersion,
118133
lambdaSources,
119134
publicDevices,
120135
})
121136
api.addRoute('POST /share', shareAPI.shareFn)
122137
api.addRoute('POST /share/confirm', shareAPI.confirmOwnershipFn)
123138
api.addRoute('GET /share/status', shareAPI.sharingStatusFingerprintFn)
124139
api.addRoute('GET /device/{id}', shareAPI.sharingStatusFn)
140+
api.addRoute('GET /device/{id}/jwt', shareAPI.deviceJwtFn)
125141

126142
const devicesAPI = new DevicesAPI(this, {
127143
baseLayer,
@@ -149,6 +165,15 @@ export class BackendStack extends Stack {
149165

150166
api.addRoute('POST /credentials', credentialsAPI.createCredentials)
151167

168+
// JWKS
169+
170+
const jwks = new JWKS(this, {
171+
baseLayer,
172+
jwtLayer: jwtLayerVersion,
173+
lambdaSources,
174+
})
175+
api.addRoute('GET /.well-known/jwks.json', jwks.jwksFn)
176+
152177
// CD
153178

154179
const cd = new ContinuousDeployment(this, {

cdk/backend.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { packBackendLambdas } from './packBackendLambdas.js'
88
import { ACMClient } from '@aws-sdk/client-acm'
99
import { getCertificateArnForDomain } from '../aws/acm.js'
1010
import { pack as packCDKLayer } from './cdkLayer.js'
11+
import { pack as packJWTLayer } from './jwtLayer.js'
1112

1213
const repoUrl = new URL(pJSON.repository.url)
1314
const repository = {
@@ -31,6 +32,7 @@ new BackendApp({
3132
lambdaSources: await packBackendLambdas(),
3233
layer: await packBaseLayer(),
3334
cdkLayer: await packCDKLayer(),
35+
jwtLayer: await packJWTLayer(),
3436
repository,
3537
gitHubOICDProviderArn: await ensureGitHubOIDCProvider({
3638
iam,

cdk/jwtLayer.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import {
2+
packLayer,
3+
type PackedLayer,
4+
} from '@bifravst/aws-cdk-lambda-helpers/layer'
5+
import type pJson from '../package.json'
6+
7+
const dependencies: Array<keyof (typeof pJson)['dependencies']> = [
8+
'jsonwebtoken',
9+
]
10+
11+
export const pack = async (): Promise<PackedLayer> =>
12+
packLayer({
13+
id: 'jwtLayer',
14+
dependencies,
15+
})

cdk/packBackendLambdas.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ export type BackendLambdas = {
1111
openSSL: PackedLambda
1212
apiHealthCheck: PackedLambda
1313
createCNAMERecord: PackedLambda
14+
jwks: PackedLambda
15+
deviceJwt: PackedLambda
1416
}
1517

1618
const pack = async (id: string) => packLambdaFromPath(id, `lambda/${id}.ts`)
@@ -28,4 +30,6 @@ export const packBackendLambdas = async (): Promise<BackendLambdas> => ({
2830
'createCNAMERecord',
2931
'cdk/resources/api/createCNAMERecord.ts',
3032
),
33+
jwks: await pack('jwks'),
34+
deviceJwt: await pack('deviceJwt'),
3135
})

cdk/resources/JWKS.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { PackedLambdaFn } from '@bifravst/aws-cdk-lambda-helpers/cdk'
2+
import { type aws_lambda as Lambda } from 'aws-cdk-lib'
3+
import { Construct } from 'constructs'
4+
import type { BackendLambdas } from '../packBackendLambdas.js'
5+
6+
/**
7+
* Provides the .well-known/jwks.json endpoint
8+
*/
9+
export class JWKS extends Construct {
10+
public readonly jwksFn: Lambda.IFunction
11+
constructor(
12+
parent: Construct,
13+
{
14+
baseLayer,
15+
jwtLayer,
16+
lambdaSources,
17+
}: {
18+
baseLayer: Lambda.ILayerVersion
19+
jwtLayer: Lambda.ILayerVersion
20+
lambdaSources: Pick<BackendLambdas, 'jwks'>
21+
},
22+
) {
23+
super(parent, 'JWKS')
24+
25+
this.jwksFn = new PackedLambdaFn(this, 'jwksFn', lambdaSources.jwks, {
26+
description: 'Serve the JWT public key',
27+
layers: [baseLayer, jwtLayer],
28+
}).fn
29+
}
30+
}

cdk/resources/ShareAPI.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,24 +8,28 @@ export class ShareAPI extends Construct {
88
public readonly shareFn: Lambda.IFunction
99
public readonly confirmOwnershipFn: Lambda.IFunction
1010
public readonly sharingStatusFn: Lambda.IFunction
11+
public readonly deviceJwtFn: Lambda.IFunction
1112
public readonly sharingStatusFingerprintFn: Lambda.IFunction
1213
constructor(
1314
parent: Construct,
1415
{
1516
domain,
1617
baseLayer,
18+
jwtLayer,
1719
lambdaSources,
1820
publicDevices,
1921
}: {
2022
domain: string
2123
publicDevices: PublicDevices
2224
baseLayer: Lambda.ILayerVersion
25+
jwtLayer: Lambda.ILayerVersion
2326
lambdaSources: Pick<
2427
BackendLambdas,
2528
| 'shareDevice'
2629
| 'confirmOwnership'
2730
| 'sharingStatus'
2831
| 'sharingStatusFingerprint'
32+
| 'deviceJwt'
2933
>
3034
},
3135
) {
@@ -111,5 +115,21 @@ export class ShareAPI extends Construct {
111115
publicDevices.publicDevicesTable.grantReadData(
112116
this.sharingStatusFingerprintFn,
113117
)
118+
119+
this.deviceJwtFn = new PackedLambdaFn(
120+
this,
121+
'deviceJwtFn',
122+
lambdaSources.deviceJwt,
123+
{
124+
description:
125+
'Returns a JWT for the device, confirming that it is shared.',
126+
layers: [baseLayer, jwtLayer],
127+
environment: {
128+
PUBLIC_DEVICES_TABLE_NAME: publicDevices.publicDevicesTable.tableName,
129+
PUBLIC_DEVICES_ID_INDEX_NAME: publicDevices.idIndex,
130+
},
131+
},
132+
).fn
133+
publicDevices.publicDevicesTable.grantReadData(this.deviceJwtFn)
114134
}
115135
}

cli/cli.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,14 @@ import { registerDeviceCommand } from './commands/register-device.js'
1313
import type { CommandDefinition } from './commands/CommandDefinition.js'
1414
import { buildContainersCommand } from './commands/build-container.js'
1515
import { env } from '../aws/env.js'
16-
import { configureNrfCloudAccount } from './commands/configure-nrfcloud-account.js'
16+
import { configureNrfCloudAccountCommand } from './commands/configure-nrfcloud-account.js'
1717
import { logsCommand } from './commands/logs.js'
1818
import { CloudWatchLogsClient } from '@aws-sdk/client-cloudwatch-logs'
19-
import { configureHello } from './commands/configure-hello.js'
20-
import { shareDevice } from './commands/share-device.js'
19+
import { configureHelloCommand } from './commands/configure-hello.js'
20+
import { shareDeviceCommand } from './commands/share-device.js'
2121
import { listDevicesCommand } from './commands/listDevices.js'
2222
import { removeDeviceCommand } from './commands/remove-device.js'
23+
import { generateJWTKeypairCommand } from './commands/generate-jwt-keypair.js'
2324

2425
const ssm = new SSMClient({})
2526
const db = new DynamoDBClient({})
@@ -53,13 +54,14 @@ const CLI = async ({ isCI }: { isCI: boolean }) => {
5354
buildContainersCommand({
5455
ecr,
5556
}),
56-
configureHello({ ssm }),
57-
configureNrfCloudAccount({ ssm }),
57+
configureHelloCommand({ ssm }),
58+
configureNrfCloudAccountCommand({ ssm }),
5859
logsCommand({
5960
stackName: STACK_NAME,
6061
cf,
6162
logs,
6263
}),
64+
generateJWTKeypairCommand({ ssm }),
6365
]
6466

6567
if (isCI) {
@@ -83,7 +85,7 @@ const CLI = async ({ isCI }: { isCI: boolean }) => {
8385
ssm,
8486
stackName: STACK_NAME,
8587
}),
86-
shareDevice({
88+
shareDeviceCommand({
8789
db,
8890
publicDevicesTableName: backendOutputs.publicDevicesTableName,
8991
idIndex: backendOutputs.publicDevicesTableIdIndexName,

cli/commands/configure-hello.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@ import {
66
deleteSettings,
77
putSetting,
88
type Settings,
9-
} from '../../hello/settings.js'
9+
} from '../../settings/hello.js'
1010

11-
export const configureHello = ({
11+
export const configureHelloCommand = ({
1212
ssm,
1313
}: {
1414
ssm: SSMClient

0 commit comments

Comments
 (0)