For NestJS, we have two modules to ease CIBA and QR Code authentication.
- DeviceAuthModule
- FrontendAuthModule
This library is powered by our Javascript libraries @ones.dev/common and @ones.dev/backend-modules about which you can read more here.
1.0 Installation
Create a file .npmrc in your project root directory and add the following line:
registry=https://npm.pantherx.org
Install the library with npm or pnpm:
npm install @ones.dev/backend-device-auth
npm install @ones.dev/backend-frontend-auth
Important: To use the module you need to register your device with IDP first, and make sure that px-device-identity-service is running. See here.
1.1 Server integration
Here’s what a typical app.module.ts file might look like:
@Module({
imports: [
HttpModule,
ConfigModule.forRoot({
isGlobal: true,
load: [() => configuration()],
}),
RedisModule.forRootAsync(RedisModule, {
imports: [ConfigModule],
useFactory: (configService: ConfigService) => {
return {
config: {
host: configService.get('backendConfig.redis.host'),
port: configService.get('backendConfig.redis.port'),
},
}
},
inject: [ConfigService],
}),
// Device authentication
DeviceAuthModule.forRootAsync(DeviceAuthModule, {
imports: [HttpModule],
inject: [ConfigService, RedisService, HttpService],
useFactory: (
configService: ConfigService,
redisService: RedisService,
httpService: HttpService
) => {
return {
redisService,
httpService,
settings: {
applicationName: configService.get('commonConfig.applicationName'),
deviceId: configService.get('systemConfig.deviceId'),
clientId: configService.get('systemConfig.clientId'),
authHost: configService.get('systemConfig.authServerUrl'),
host: configService.get('backendConfig.host'),
deviceIdentityUrl: configService.get('backendConfig.oidc.deviceIdentityUrl'),
},
}
},
}),
// Frontend authentication
FrontendAuthModule.forRootAsync(FrontendAuthModule, {
imports: [ConfigModule, HttpModule],
inject: [ConfigService, RedisService, DeviceAuthService, HttpService],
useFactory: (
configService: ConfigService,
redisService: RedisService,
deviceAuthService: DeviceAuthService,
httpService: HttpService,
) => {
return {
redisService,
deviceAuthService,
deviceAuthConfig: {
applicationName: configService.get('commonConfig.applicationName'),
deviceId: configService.get('systemConfig.deviceId'),
clientId: configService.get('systemConfig.clientId'),
authHost: configService.get('systemConfig.authServerUrl'),
host: configService.get('backendConfig.host'),
deviceIdentityUrl: configService.get('backendConfig.oidc.deviceIdentityUrl'),
},
httpService,
config: {
applicationName: configService.get('commonConfig.applicationName'),
authHost: configService.get('systemConfig.authServerUrl'),
host: configService.get('backendConfig.host'),
},
}
},
}),
],
controllers: [],
providers: [],
})
export class AppModule {}
Here’s an excerpt of the tests, to illustrate the usage:
const repoRoot = findRepoRootSync()
describe('Device Auth', () => {
let app: INestApplication;
let mobileDeviceAuth: MobileDeviceAuthentication
let masterAccessToken: string
let application: ApplicationEntity
let mobileCliConfig: CLIConfig
beforeAll(async () => {
const config = await loadSystemConfig()
const appConfig = configuration()
mobileCliConfig = await getCliConfig(`${repoRoot}/`)
mobileDeviceAuth = new MobileDeviceAuthentication({
name: 'Mobile device',
idpBaseUrl: config.authServerUrl,
deviceClientId: mobileCliConfig.properties['mobile.client_id'],
authJwtProperties: new AuthJwtProperties(),
deviceIdentityUrl: appConfig.backendConfig.oidc.deviceIdentityUrl,
})
const keyPath = `${repoRoot}/.cli/.cli.mobile.pem`
const keys = await loadKeys(keyPath)
mobileDeviceAuth.loadKeys(keys)
const newApplicationDomain = `domain-${Date.now()}.com`
masterAccessToken = await runMasterCibaAuth(repoRoot, appConfig.backendConfig.oidc.deviceIdentityUrl)
const moduleRef = await Test.createTestingModule({
imports: [
ConfigModule.forRoot({
isGlobal: true,
load: [() => configuration()],
}),
HttpModule,
RedisModule.forRootAsync(RedisModule, {
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (configService: ConfigService) => {
return {
config: {
host: configService.get('backendConfig.redis.host'),
port: configService.get('backendConfig.redis.port'),
},
global: true,
}
},
}),
DeviceAuthModule.forRootAsync(DeviceAuthModule, {
imports: [HttpModule, ConfigModule],
inject: [HttpService, ConfigService],
useFactory: (httpService: HttpService, configService: ConfigService) => ({
httpService,
settings: {
applicationName: 'testing',
deviceId: config.deviceId,
clientId: config.clientId,
authHost: config.authServerUrl,
host: `http://${newApplicationDomain}`,
deviceIdentityUrl: configService.get('backendConfig.oidc.deviceIdentityUrl'),
},
global: true,
}),
}),
FrontendAuthModule.forRootAsync(FrontendAuthModule, {
imports: [ConfigModule, HttpModule],
inject: [ConfigService, HttpService],
useFactory: (
httpService: HttpService,
) => {
return {
httpService,
config: {
applicationName: 'testing',
authHost: config.authServerUrl,
host: `http://${newApplicationDomain}`,
},
}
},
}),
],
})
.compile();
app = moduleRef.createNestApplication();
await app.init();
// Register application on IDP
const newApplication = await cmApplications.create(axiosConfigDefault(masterAccessToken), {
name: 'Some application',
type: APPLICATION_TYPE.SERVER,
metadata: {
hostname: newApplicationDomain,
isDependent: true,
clientIdentifier: 'some-app',
},
device: {
id: config.deviceId,
clientId: config.clientId,
} as DeviceEntity,
isEnabled: true,
})
application = newApplication.data
});
it(`Get device JWT`, async () => {
const token = await app.get(DeviceAuthService).makeDeviceJwt();
expect(token).toBeDefined();
});
it(`Get device access token`, async () => {
const token = await app.get(DeviceAuthService).makeDeviceAccessToken();
expect(token).toBeDefined();
})
/**
* NEW QR Login
*/
let qrSessionId: string
it('Make QR session', async () => {
const session = await app.get(FrontendAuthService).makeQrAuthSession(
'10.10.10.100'
)
expect(session).toBeDefined()
expect(session.cbUrl.endsWith('/auth/oidc/bc-authorize')).toBeTruthy()
qrSessionId = session.sessionId
})
it('Check QR session', async () => {
const session = await app.get(FrontendAuthService).verifyQrAuthSession(qrSessionId)
expect(session).toBeDefined()
expect(session.cbUrl.endsWith('/auth/oidc/bc-authorize')).toBeTruthy()
})
it('[LEGACY] Make QR session http', async () => {
const response = await request(app.getHttpServer())
.get('/auth/qr')
.expect(200)
expect(response.body).toBeDefined()
expect(response.body.deviceId).toBeDefined()
expect(response.body.auth_req_id).toBeDefined()
expect(response.body.exp).toBeDefined()
})
let authRequestIdGlob: string
it('Make CIBA request from QR session ID', async () => {
const loginHintToken = await mobileDeviceAuth.getLoginHintToken()
const response = await request(app.getHttpServer())
.post('/auth/oidc/bc-authorize')
.send({
loginHintKind: loginHintToken.kind,
userIdentifier: loginHintToken.value,
sessionId: qrSessionId,
resource: CIBA_AUTH_REQUEST_RESOURCE.IDP,
scope: 'openid offline_access profile',
})
expect(response.body.auth_req_id).toBeDefined()
authRequestIdGlob = response.body.auth_req_id
})
it('Check CIBA status: PENDING', async () => {
expect(authRequestIdGlob).toBeDefined()
const statusRequest: CibaAuthStatusRequestFrontendDto = {
grant_type: 'urn:openid:params:grant-type:ciba',
authRequestId: authRequestIdGlob,
}
const response = await request(app.getHttpServer())
.post(`/auth/oidc/token`)
.send(statusRequest)
expect(response.status).toBe(400)
expect(response.body.error).toBe('authorization_pending')
})
it('[user mobile] Approve request', async () => {
const pendingCiba = await mobileDeviceAuth.listPendingCibaRequests()
expect(pendingCiba).toBeDefined()
const cibaRequest = pendingCiba.find((ciba) => ciba.authRequestId === authRequestIdGlob)
expect(cibaRequest).toBeDefined()
await mobileDeviceAuth.respondToPendingCibaRequest(cibaRequest.authRequestId, 'approved')
})
let refresh_token: string
it('Check CIBA status: SUCCESS', async () => {
const statusRequest: CibaAuthStatusRequestFrontendDto = {
grant_type: 'urn:openid:params:grant-type:ciba',
authRequestId: authRequestIdGlob,
}
const response = await request(app.getHttpServer())
.post(`/auth/oidc/token`)
.send(statusRequest)
expect(response.status).toBe(201)
expect(response.body.access_token).toBeDefined()
expect(response.body.refresh_token).toBeDefined()
expect(response.body.scope).toBe('openid offline_access profile')
refresh_token = response.body.refresh_token
})
it('Refresh token', async () => {
const refreshRequest: RefreshTokenRequestFrontendDto = {
grant_type: 'refresh_token',
refresh_token: refresh_token,
}
const response = await request(app.getHttpServer())
.post(`/auth/oidc/token`)
.send(refreshRequest)
expect(response.status).toBe(201)
expect(response.body.access_token).toBeDefined()
expect(response.body.refresh_token).toBeDefined()
expect(response.body.scope).toBe('openid offline_access profile')
})
it('Make CIBA request with resource', async () => {
const loginHintToken = await mobileDeviceAuth.getLoginHintToken()
const response = await request(app.getHttpServer())
.post('/auth/oidc/bc-authorize')
.send({
loginHintKind: loginHintToken.kind,
userIdentifier: loginHintToken.value,
sessionId: qrSessionId,
resource: CIBA_AUTH_REQUEST_RESOURCE.DEVICE,
scope: 'openid offline_access profile api:read api:write',
})
expect(response.body.auth_req_id).toBeDefined()
const authRequestId = response.body.auth_req_id
await mobileDeviceAuth.respondToPendingCibaRequest(authRequestId, 'approved')
const statusRequest: CibaAuthStatusRequestFrontendDto = {
grant_type: 'urn:openid:params:grant-type:ciba',
authRequestId,
}
const tokenResponse = await request(app.getHttpServer())
.post(`/auth/oidc/token`)
.send(statusRequest)
expect(tokenResponse.status).toBe(201)
expect(isJWT(tokenResponse.body.access_token)).toBeTruthy()
expect(tokenResponse.body.scope).toBe('')
const verificationResult = await app.get(FrontendAuthService).validateJwt(tokenResponse.body.access_token)
expect(verificationResult.jti).toBeDefined()
})
it('Make CIBA request from phone, with QR session', async () => {
const session = await app.get(FrontendAuthService).makeQrAuthSession(
'10.10.10.100'
)
// Respond from phone
const loginHintToken = await mobileDeviceAuth.getLoginHintToken()
const ciba = await request(app.getHttpServer())
.post('/auth/oidc/bc-authorize')
.send({
loginHintKind: loginHintToken.kind,
userIdentifier: loginHintToken.value,
sessionId: session.sessionId,
resource: CIBA_AUTH_REQUEST_RESOURCE.DEVICE,
scope: 'openid offline_access profile api:read api:write',
})
// Approve
try {
await mobileDeviceAuth.respondToPendingCibaRequest(
ciba.body.auth_req_id, 'approved'
)
} catch (err) {
console.error((err as AxiosError).response.data)
}
// Check status from client
const status = await app.get(FrontendAuthService).verifyQrAuthSession(session.sessionId)
expect(status.authRequestId).toBe(ciba.body.auth_req_id)
// Check ciba status from client
const cibaStatus = await app.get(FrontendAuthService).cibaStatus(ciba.body.auth_req_id)
expect(cibaStatus.access_token).toBeDefined()
})
it('Make CIBA request with username and resource', async () => {
const response = await request(app.getHttpServer())
.post('/auth/oidc/bc-authorize')
.send({
loginHintKind: LOGIN_HINT_KIND.LOGIN_HINT,
userIdentifier: mobileCliConfig.properties['mobile.username'],
resource: CIBA_AUTH_REQUEST_RESOURCE.DEVICE,
scope: 'openid offline_access profile api:read api:write',
})
expect(response.body.auth_req_id).toBeDefined()
const authRequestId = response.body.auth_req_id
await mobileDeviceAuth.respondToPendingCibaRequest(authRequestId, 'approved')
const statusRequest: CibaAuthStatusRequestFrontendDto = {
grant_type: 'urn:openid:params:grant-type:ciba',
authRequestId,
}
const tokenResponse = await request(app.getHttpServer())
.post(`/auth/oidc/token`)
.send(statusRequest)
expect(tokenResponse.status).toBe(201)
expect(isJWT(tokenResponse.body.access_token)).toBeTruthy()
expect(tokenResponse.body.scope).toBe('')
const verificationResult = await app.get(FrontendAuthService).validateJwt(tokenResponse.body.access_token)
expect(verificationResult.jti).toBeDefined()
})
afterAll(async () => {
await app.close();
});
});
1.2 Frontend integration
Coming soon…