@ministryofjustice/hmpps-prisoner-auth 0.0.2 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ministryofjustice/hmpps-prisoner-auth",
3
- "version": "0.0.2",
3
+ "version": "0.0.3",
4
4
  "description": "Middleware for providing authentication against Launchpad Auth",
5
5
  "author": "hmpps-launchpad-devs",
6
6
  "license": "MIT",
@@ -18,8 +18,7 @@
18
18
  "types": "./dist/index.d.ts",
19
19
  "files": [
20
20
  "*.md",
21
- "dist/**/*",
22
- "src/**/*"
21
+ "dist/**/*"
23
22
  ],
24
23
  "engines": {
25
24
  "node": "24"
package/src/main/index.ts DELETED
@@ -1,3 +0,0 @@
1
- export { default as PrisonerAuth, type PrisonerAuthOptions } from './prisonerAuth'
2
- export { minutes, seconds, milliseconds, nothing, TimeSpan } from './timeSpans'
3
- export type { LaunchpadUser } from './launchpadUser'
@@ -1,41 +0,0 @@
1
- import jwt from 'jsonwebtoken'
2
- import { userFromTokens } from './launchpadUser'
3
-
4
- describe('userFromTokens', () => {
5
- const aJwtOf = (obj: object): string => jwt.sign(obj, 'secret')
6
-
7
- const idToken = aJwtOf({
8
- name: 'Test User',
9
- given_name: 'Test',
10
- family_name: 'User',
11
- establishment: { agency_id: 'CKI' },
12
- sub: '12345',
13
- })
14
- const accessToken = aJwtOf({ hello: 'world' })
15
- const refreshToken = aJwtOf({ hello: 'world' })
16
-
17
- it('sets the given tokens on the user for later use (refreshing tokens)', () => {
18
- const result = userFromTokens({ idToken, accessToken, refreshToken })
19
-
20
- expect(result.idToken).toEqual(idToken)
21
- expect(result.accessToken).toEqual(accessToken)
22
- expect(result.refreshToken).toEqual(refreshToken)
23
- })
24
-
25
- it('sets the establishment from the idToken', () => {
26
- const result = userFromTokens({ idToken, accessToken, refreshToken })
27
- expect(result.establishment).toEqual({ agency_id: 'CKI' })
28
- })
29
-
30
- it('sets the name fields from the idToken', () => {
31
- const result = userFromTokens({ idToken, accessToken, refreshToken })
32
- expect(result.name).toEqual('Test User')
33
- expect(result.givenName).toEqual('Test')
34
- expect(result.familyName).toEqual('User')
35
- })
36
-
37
- it('sets authSource to give compatibility with the HmppsUser interface', () => {
38
- const result = userFromTokens({ idToken, accessToken, refreshToken })
39
- expect(result.authSource).toEqual('prisoner-auth')
40
- })
41
- })
@@ -1,42 +0,0 @@
1
- import { IdToken, RawTokens, tokenFromJwt } from './tokens'
2
-
3
- export type LaunchpadUser = {
4
- authSource: 'prisoner-auth'
5
- idToken: string
6
- refreshToken: string
7
- accessToken: string
8
- establishment: IdToken['establishment']
9
- name: string
10
- givenName: string
11
- familyName: string
12
-
13
- // The following fields are for compatibility with HmppsUser only
14
- token: string
15
- username: string
16
- userId: string
17
- displayName: string
18
- userRoles: string[]
19
- }
20
-
21
- export const userFromTokens = ({ idToken, accessToken, refreshToken }: RawTokens): LaunchpadUser => {
22
- const { establishment, name, given_name: givenName, family_name: familyName, sub } = tokenFromJwt<IdToken>(idToken)
23
- const authSource = 'prisoner-auth'
24
-
25
- return {
26
- authSource,
27
- idToken,
28
- refreshToken,
29
- accessToken,
30
- establishment,
31
- name,
32
- givenName,
33
- familyName,
34
-
35
- // The following fields are for compatibility with HmppsUser only
36
- token: idToken,
37
- username: name,
38
- userId: sub,
39
- displayName: name,
40
- userRoles: [],
41
- }
42
- }
@@ -1,117 +0,0 @@
1
- import { minutes, timeAgo, timeFromNow } from './timeSpans'
2
- import PrisonerAuth, { PrisonerAuthOptions } from './prisonerAuth'
3
- import { LaunchpadUser } from './launchpadUser'
4
- import { AccessToken, IdToken, RefreshToken, tokenFromJwt } from './tokens'
5
- import { aJwtOf, aUserWith } from './testUtils'
6
-
7
- describe('PrisonerAuth', () => {
8
- const options: PrisonerAuthOptions = {
9
- launchpadAuthUrl: 'https://launchpad.example.com',
10
- clientId: 'cf837899-2a3c-4838-aa83-5fb76bd58df1',
11
- clientSecret: '445f30b2-1010-4bcb-8cba-447b70463bd0',
12
- tokenMinimumLifespan: minutes(5),
13
- }
14
-
15
- describe('setting options', () => {
16
- it('sets sane defaults for the URL options', () => {
17
- const prisonerAuth = new PrisonerAuth(options)
18
- expect(prisonerAuth.authorizationUrl).toEqual('https://launchpad.example.com/v1/oauth2/authorize')
19
- expect(prisonerAuth.tokenUrl).toEqual('https://launchpad.example.com/v1/oauth2/token')
20
- })
21
-
22
- it('allows custom override of the URL options', () => {
23
- const prisonerAuth = new PrisonerAuth({
24
- ...options,
25
- authorizationUrl: 'https://auth.com',
26
- tokenUrl: 'https://token.com',
27
- })
28
- expect(prisonerAuth.authorizationUrl).toEqual('https://auth.com')
29
- expect(prisonerAuth.tokenUrl).toEqual('https://token.com')
30
- })
31
-
32
- it('sets the client credentials', () => {
33
- const prisonerAuth = new PrisonerAuth(options)
34
- expect(prisonerAuth.clientId).toEqual('cf837899-2a3c-4838-aa83-5fb76bd58df1')
35
- expect(prisonerAuth.clientSecret).toEqual('445f30b2-1010-4bcb-8cba-447b70463bd0')
36
- })
37
-
38
- it('allows overriding of the callbackUrl', () => {
39
- const prisonerAuth = new PrisonerAuth({ ...options, callbackUrl: '/my/call/back' })
40
- expect(prisonerAuth.callbackUrl).toEqual('/my/call/back')
41
- })
42
-
43
- it('sets a default callbackUrl when none provided', () => {
44
- const prisonerAuth = new PrisonerAuth(options)
45
- expect(prisonerAuth.callbackUrl).toEqual('/sign-in/callback')
46
- })
47
-
48
- it('allows overriding of the scopes', () => {
49
- const prisonerAuth = new PrisonerAuth({ ...options, scope: 'user.basic.read' })
50
- expect(prisonerAuth.scope).toEqual('user.basic.read')
51
- })
52
-
53
- it('sets a default scope of all read only scopes when no scopes given', () => {
54
- const prisonerAuth = new PrisonerAuth(options)
55
- expect(prisonerAuth.scope).toEqual(['user.basic.read', 'user.establishment.read', 'user.booking.read'])
56
- })
57
- })
58
-
59
- describe('validateAndRefreshUser', () => {
60
- describe('when the users idToken has not yet expired', () => {
61
- it('returns the user as it is', async () => {
62
- const prisonerAuth = new PrisonerAuth(options)
63
- const user = aUserWith({
64
- idTokenExp: timeFromNow(minutes(6)).seconds,
65
- refreshTokenExp: timeFromNow(minutes(6)).seconds,
66
- })
67
-
68
- const result = await prisonerAuth.validateAndRefreshUser(user as LaunchpadUser)
69
- const idToken = tokenFromJwt<IdToken>(result.idToken)
70
-
71
- expect(idToken.iat).toEqual(123_456_789)
72
- })
73
- })
74
-
75
- describe('when the users idToken has expired', () => {
76
- describe('when the users refreshToken has not yet expired', () => {
77
- it('returns a new set of user tokens for the given user', async () => {
78
- const prisonerAuth = new PrisonerAuth({
79
- ...options,
80
- tokenFetcher: jest.fn().mockResolvedValue({
81
- idToken: aJwtOf({ iat: 987_654_321 }),
82
- refreshToken: aJwtOf({ iat: 987_654_322 }),
83
- accessToken: aJwtOf({ iat: 987_654_323 }),
84
- }),
85
- })
86
- const user = aUserWith({
87
- idTokenExp: timeAgo(minutes(3)).seconds,
88
- refreshTokenExp: timeFromNow(minutes(6)).seconds,
89
- })
90
-
91
- const result = await prisonerAuth.validateAndRefreshUser(user as LaunchpadUser)
92
- const idToken = tokenFromJwt<IdToken>(result.idToken)
93
- const refreshToken = tokenFromJwt<RefreshToken>(result.refreshToken)
94
- const accessToken = tokenFromJwt<AccessToken>(result.accessToken)
95
-
96
- expect(idToken.iat).toEqual(987_654_321)
97
- expect(refreshToken.iat).toEqual(987_654_322)
98
- expect(accessToken.iat).toEqual(987_654_323)
99
- })
100
- })
101
-
102
- describe('when the users refreshToken has expired', () => {
103
- it('throws an error as we cant continue', async () => {
104
- const prisonerAuth = new PrisonerAuth(options)
105
- const user = aUserWith({
106
- idTokenExp: timeAgo(minutes(3)).seconds,
107
- refreshTokenExp: timeFromNow(minutes(3)).seconds,
108
- })
109
-
110
- await expect(async () => {
111
- await prisonerAuth.validateAndRefreshUser(user as LaunchpadUser)
112
- }).rejects.toThrow('Refresh token expired')
113
- })
114
- })
115
- })
116
- })
117
- })
@@ -1,174 +0,0 @@
1
- import OpenIDConnectStrategy, { Profile, VerifyCallback, VerifyFunction } from 'passport-openidconnect'
2
- import { IdToken, oauthClientToken, RefreshToken, Scope, tokenFromJwt, tokenFetcher, TokenFetcher } from './tokens'
3
- import { LaunchpadUser, userFromTokens } from './launchpadUser'
4
- import { seconds, timeFromNow, TimeSpan } from './timeSpans'
5
-
6
- export type PrisonerAuthOptions = {
7
- /** The full URL of the Launchpad Auth instance */
8
- launchpadAuthUrl: string
9
-
10
- /**
11
- * Optional: The full URL of the authorisation path on the Launchpad Auth instance.
12
- *
13
- * If omitted, authorizationUrl will default to $launchpadAuthUrl/v1/oauth2/authorize
14
- */
15
- authorizationUrl?: string
16
-
17
- /**
18
- * Optional: The full URL of the token path on the Launchpad Auth instance.
19
- *
20
- * If omitted, tokenUrl will default to $launchpadAuthUrl/v1/oauth2/token
21
- */
22
- tokenUrl?: string
23
-
24
- /**
25
- * Optional: The URL or path that Launchpad Auth will be told to call back to with the tokens.
26
- *
27
- * If omitted, callbackUrl will default to /sign-in/callback
28
- */
29
- callbackUrl?: string
30
-
31
- /** The Client ID of this application to send to Launchpad Auth */
32
- clientId: string
33
- /** The Client Secret of this application to send to Launchpad Auth */
34
- clientSecret: string
35
- /**
36
- * Optional: The scope or scopes to request on behalf of the user.
37
- *
38
- * If omitted, scope will default to user.basic.read, user.establishment.read and user.booking.read
39
- */
40
- scope?: Scope | Scope[]
41
- /**
42
- * Optional: Whether or not to use nonce, not recommended to be disabled except for integration tests
43
- *
44
- * If ommitted. defaults to true
45
- */
46
- nonce?: boolean
47
-
48
- /**
49
- * A time span specifying the least amount of time a token must have left before it is considered expired.
50
- *
51
- * @example
52
- *
53
- * Token will be considered expired when its exp value is in the past.
54
- * ```ts
55
- * nothing()
56
- * ```
57
- *
58
- * Token will be considered expired unless its exp value is **AT LEAST 5** minutes in the future from time of checking.
59
- * ```ts
60
- * minutes(5)
61
- * ```
62
- *
63
- * @see TimeSpan
64
- */
65
- tokenMinimumLifespan: TimeSpan
66
-
67
- /**
68
- * Optional: Token fetcher implementation. Intended for unit testing PrisonerAuth
69
- *
70
- * @see TokenFetcher
71
- */
72
- tokenFetcher?: TokenFetcher
73
- }
74
-
75
- export default class PrisonerAuth {
76
- readonly launchpadAuthUrl: string
77
-
78
- readonly authorizationUrl: string
79
-
80
- readonly tokenUrl: string
81
-
82
- readonly callbackUrl: string
83
-
84
- readonly clientId: string
85
-
86
- readonly clientSecret: string
87
-
88
- readonly scope: Scope | Scope[]
89
-
90
- readonly nonce: boolean
91
-
92
- readonly tokenMinimumLifespan: TimeSpan
93
-
94
- readonly tokenFetcher: TokenFetcher
95
-
96
- constructor(options: PrisonerAuthOptions) {
97
- this.launchpadAuthUrl = options.launchpadAuthUrl
98
- this.authorizationUrl = options.authorizationUrl ?? `${options.launchpadAuthUrl}/v1/oauth2/authorize`
99
- this.tokenUrl = options.tokenUrl ?? `${options.launchpadAuthUrl}/v1/oauth2/token`
100
- this.callbackUrl = options.callbackUrl ?? '/sign-in/callback'
101
- this.clientId = options.clientId
102
- this.clientSecret = options.clientSecret
103
- this.scope = options.scope ?? ['user.basic.read', 'user.establishment.read', 'user.booking.read']
104
- this.nonce = options.nonce ?? true
105
- this.tokenMinimumLifespan = options.tokenMinimumLifespan
106
- this.tokenFetcher = options.tokenFetcher ?? tokenFetcher
107
- }
108
-
109
- /**
110
- *
111
- * @returns OpenIDConnectStrategy ready for use with passport
112
- */
113
- passportStrategy(): OpenIDConnectStrategy {
114
- return new OpenIDConnectStrategy(
115
- {
116
- issuer: this.launchpadAuthUrl,
117
- authorizationURL: this.authorizationUrl,
118
- tokenURL: this.tokenUrl,
119
- callbackURL: this.callbackUrl,
120
- clientID: this.clientId,
121
- clientSecret: this.clientSecret,
122
- scope: this.scope,
123
- userInfoURL: '',
124
- skipUserProfile: true,
125
- nonce: this.nonce ? 'true' : undefined,
126
- customHeaders: { authorization: this.authorizationToken() },
127
- },
128
- ((
129
- _issuer: string,
130
- _profile: Profile,
131
- _context: object,
132
- idToken: string,
133
- accessToken: string,
134
- refreshToken: string,
135
- done: VerifyCallback,
136
- ) => done(null, userFromTokens({ idToken, accessToken, refreshToken }))) as VerifyFunction,
137
- )
138
- }
139
-
140
- /**
141
- * Checks the expiry of a given LaunchpadUser idToken and refreshToken.
142
- * - If the idToken has expired it will attempt to refresh it using the refreshToken.
143
- * - If the refreshToken has expired it will throw an Error.
144
- *
145
- * This method will always return a LaunchpadUser so it is suggested to updated your local copy and it will keep the tokens up to date.
146
- *
147
- * @param user - The LaunchpadUser instance to check token expiry on.
148
- * @returns A LaunchpadUser is always returned, either the given user or a user with refreshed tokens.
149
- * @throws Error is thrown if the refreshToken has expired, the user must sign in again from scratch.
150
- */
151
- async validateAndRefreshUser(user: LaunchpadUser): Promise<LaunchpadUser> {
152
- const idToken = tokenFromJwt<IdToken>(user.idToken)
153
- const refreshToken = tokenFromJwt<RefreshToken>(user.refreshToken)
154
- const tokenExpiryTime = timeFromNow(this.tokenMinimumLifespan)
155
-
156
- // Id Token still valid, return the current user
157
- if (seconds(idToken.exp).isGreaterThanOrEqualTo(tokenExpiryTime)) {
158
- return user
159
- }
160
-
161
- // Refresh token expired, cannot continue
162
- if (seconds(refreshToken.exp).isLessThan(tokenExpiryTime)) {
163
- throw new Error('Refresh token expired')
164
- }
165
-
166
- // Refresh the user tokens
167
- const newTokens = await this.tokenFetcher(user.refreshToken, this.tokenUrl, this.authorizationToken())
168
- return userFromTokens(newTokens)
169
- }
170
-
171
- private authorizationToken(): string {
172
- return oauthClientToken(this.clientId, this.clientSecret)
173
- }
174
- }
@@ -1,10 +0,0 @@
1
- // eslint-disable-next-line import/no-extraneous-dependencies
2
- import jwt from 'jsonwebtoken'
3
-
4
- export const aJwtOf = (obj: object): string => jwt.sign(obj, 'secret')
5
-
6
- export const aUserWith = ({ idTokenExp, refreshTokenExp }: { idTokenExp: number; refreshTokenExp: number }) => ({
7
- idToken: aJwtOf({ exp: idTokenExp, iat: 123_456_789, sub: 'USER_A' }),
8
- refreshToken: aJwtOf({ exp: refreshTokenExp, iat: 123_456_789, sub: 'USER_A' }),
9
- accessToken: aJwtOf({ exp: refreshTokenExp, iat: 123_456_789, sub: 'USER_A' }),
10
- })
@@ -1,129 +0,0 @@
1
- import { milliseconds, minutes, seconds, timeAgo, timeFromNow, TimeSpan } from './timeSpans'
2
-
3
- describe('time spans', () => {
4
- describe('minutes', () => {
5
- const cases = [
6
- [1, 1, 60, 60000],
7
- [5, 5, 300, 300000],
8
- [0.5, 0.5, 30, 30000],
9
- [0, 0, 0, 0],
10
- ]
11
-
12
- test.each(cases)(
13
- 'Given %p minute(s), minutes is %p, seconds is %p and milliseconds is %p',
14
- (example, expectedMinutes, expectedSeconds, expectedMilliseconds) => {
15
- const subject = minutes(example)
16
- expect(subject.minutes).toEqual(expectedMinutes)
17
- expect(subject.seconds).toEqual(expectedSeconds)
18
- expect(subject.milliseconds).toEqual(expectedMilliseconds)
19
- },
20
- )
21
- })
22
-
23
- describe('seconds', () => {
24
- const cases = [
25
- [1, 0.01666667, 1, 1000],
26
- [60, 1, 60, 60000],
27
- [120, 2, 120, 120000],
28
- ]
29
-
30
- test.each(cases)(
31
- 'Given %p seconds(s), minutes is %p, seconds is %p and milliseconds is %p',
32
- (example, expectedMinutes, expectedSeconds, expectedMilliseconds) => {
33
- const subject = seconds(example)
34
- expect(subject.minutes).toBeCloseTo(expectedMinutes)
35
- expect(subject.seconds).toEqual(expectedSeconds)
36
- expect(subject.milliseconds).toEqual(expectedMilliseconds)
37
- },
38
- )
39
- })
40
-
41
- describe('milliseconds', () => {
42
- const cases = [
43
- [1, 0.00001667, 0.001, 1],
44
- [1000, 0.01666667, 1, 1000],
45
- ]
46
-
47
- test.each(cases)(
48
- 'Given %p milliseconds(s), minutes is %p, seconds is %p and milliseconds is %p',
49
- (example, expectedMinutes, expectedSeconds, expectedMilliseconds) => {
50
- const subject = milliseconds(example)
51
- expect(subject.minutes).toBeCloseTo(expectedMinutes)
52
- expect(subject.seconds).toEqual(expectedSeconds)
53
- expect(subject.milliseconds).toEqual(expectedMilliseconds)
54
- },
55
- )
56
- })
57
-
58
- describe('isEqualTo', () => {
59
- const cases: [string, TimeSpan, TimeSpan, boolean][] = [
60
- ['60 seconds is equal to 1 minute', seconds(60), minutes(1), true],
61
- ['120 seconds is equal to 1 and a half minutes', seconds(90), minutes(1.5), true],
62
- ['2 and a half seconds is equal to 2500 milliseconds', seconds(2.5), milliseconds(2500), true],
63
- ['120 minutes is equal to 7200 seconds', minutes(120), seconds(7200), true],
64
- ['120 minutes is not equal to 7000 seconds', minutes(120), seconds(7000), false],
65
- ['1 seconds is not equal to 1 minutes', seconds(1), minutes(1), false],
66
- ]
67
-
68
- test.each(cases)('%s', (testCaseDescription, example1, example2, expectedResult) => {
69
- expect(example1.isEqualTo(example2)).toBe(expectedResult)
70
- })
71
- })
72
-
73
- describe('isGreaterThan', () => {
74
- const cases: [string, TimeSpan, TimeSpan, boolean][] = [
75
- ['61 seconds is greater than 1 minute', seconds(61), minutes(1), true],
76
- ['125 seconds is greater than 1 and a half minutes', seconds(121), minutes(1.5), true],
77
- ['60 seconds is not greater than 1 minute', seconds(60), minutes(1), false],
78
- ['1 minute is not greater than 61 seconds', minutes(1), seconds(61), false],
79
- ]
80
-
81
- test.each(cases)('%s', (testCaseDescription, example1, example2, expectedResult) => {
82
- expect(example1.isGreaterThan(example2)).toBe(expectedResult)
83
- })
84
- })
85
-
86
- describe('timeAgo', () => {
87
- const dateNowInMs: number = 1770000000000
88
-
89
- beforeEach(() => {
90
- Date.now = jest.fn(() => dateNowInMs)
91
- })
92
-
93
- const cases: [string, number, TimeSpan][] = [
94
- ['1 millisecond', dateNowInMs - 1, milliseconds(1)],
95
- ['1 second', dateNowInMs - 1000, seconds(1)],
96
- ['30 seconds', dateNowInMs - 30000, seconds(30)],
97
- ['2 minutes', dateNowInMs - 120000, minutes(2)],
98
- ]
99
-
100
- test.each(cases)(
101
- 'Given a current time in milliseconds 1770000000000 and a timespan of %p, the expected past timespan in milliseconds is %p',
102
- (description, expectedMilliseconds, example) => {
103
- expect(timeAgo(example).milliseconds).toEqual(expectedMilliseconds)
104
- },
105
- )
106
- })
107
-
108
- describe('timeFromNow', () => {
109
- const dateNowInMs: number = 1770000000000
110
-
111
- beforeEach(() => {
112
- Date.now = jest.fn(() => dateNowInMs)
113
- })
114
-
115
- const cases: [string, number, TimeSpan][] = [
116
- ['1 millisecond', dateNowInMs + 1, milliseconds(1)],
117
- ['1 second', dateNowInMs + 1000, seconds(1)],
118
- ['30 seconds', dateNowInMs + 30000, seconds(30)],
119
- ['2 minutes', dateNowInMs + 120000, minutes(2)],
120
- ]
121
-
122
- test.each(cases)(
123
- 'Given a current time in milliseconds 1770000000000 and a timespan of %p, the expected future timespan in milliseconds is %p',
124
- (description, expectedMilliseconds, example) => {
125
- expect(timeFromNow(example).milliseconds).toEqual(expectedMilliseconds)
126
- },
127
- )
128
- })
129
- })
@@ -1,74 +0,0 @@
1
- // A TimeSpan allows you to give meaning to a number and then read that number back and compare them in different formats
2
- // ie.
3
- // seconds(60).minutes === 1
4
- // seconds(30).seconds === 30
5
- // minutes(1).seconds === 60
6
- // milliseconds(1000).seconds === 1
7
- // milliseconds(2000).isEqualTo(seconds(2)) #=> true
8
- // minutes(5).
9
- // This allows a caller to just accept a TimeSpan and then work in the units it cares about
10
-
11
- export const minutes = (numMinutes: number): TimeSpan => TimeSpan.minutes(numMinutes)
12
-
13
- export const seconds = (numSeconds: number): TimeSpan => TimeSpan.seconds(numSeconds)
14
-
15
- export const milliseconds = (numMilliseconds: number): TimeSpan => TimeSpan.milliseconds(numMilliseconds)
16
-
17
- export const nothing = (): TimeSpan => TimeSpan.nothing()
18
-
19
- // Get a time span in the past
20
- export const timeAgo = (ago: TimeSpan) => milliseconds(Date.now() - ago.milliseconds)
21
-
22
- // Get a time span in the future
23
- export const timeFromNow = (fromNow: TimeSpan) => milliseconds(Date.now() + fromNow.milliseconds)
24
-
25
- // Implementation
26
- export class TimeSpan {
27
- readonly minutes: number
28
-
29
- readonly seconds: number
30
-
31
- readonly milliseconds: number
32
-
33
- private constructor(numMinutes: number, numSeconds: number, numMilliseconds: number) {
34
- this.minutes = numMinutes
35
- this.seconds = numSeconds
36
- this.milliseconds = numMilliseconds
37
- }
38
-
39
- static minutes(numMinutes: number): TimeSpan {
40
- return new TimeSpan(numMinutes, numMinutes * 60, TimeSpan.seconds(numMinutes * 60).milliseconds)
41
- }
42
-
43
- static seconds(numSeconds: number): TimeSpan {
44
- return new TimeSpan(numSeconds / 60, numSeconds, numSeconds * 1000)
45
- }
46
-
47
- static milliseconds(numMilliseconds: number): TimeSpan {
48
- return new TimeSpan(TimeSpan.seconds(numMilliseconds / 1000).minutes, numMilliseconds / 1000, numMilliseconds)
49
- }
50
-
51
- static nothing(): TimeSpan {
52
- return new TimeSpan(0, 0, 0)
53
- }
54
-
55
- isGreaterThan(t: TimeSpan): boolean {
56
- return this.milliseconds > t.milliseconds
57
- }
58
-
59
- isGreaterThanOrEqualTo(t: TimeSpan): boolean {
60
- return this.milliseconds >= t.milliseconds
61
- }
62
-
63
- isLessThan(t: TimeSpan): boolean {
64
- return this.milliseconds < t.milliseconds
65
- }
66
-
67
- isLessThanOrEqualTo(t: TimeSpan): boolean {
68
- return this.milliseconds <= t.milliseconds
69
- }
70
-
71
- isEqualTo(t: TimeSpan): boolean {
72
- return this.milliseconds === t.milliseconds
73
- }
74
- }
@@ -1,65 +0,0 @@
1
- import superagent from 'superagent'
2
-
3
- export type IdToken = {
4
- name: string
5
- given_name: string
6
- family_name: string
7
- nonce?: string
8
- iat: number
9
- aud: string
10
- sub: string
11
- exp: number // exp is in seconds
12
- booking: { id: string }
13
- establishment: {
14
- agency_id: string
15
- name: string
16
- display_name: string
17
- youth: boolean
18
- }
19
- iss: string
20
- }
21
-
22
- export type RefreshToken = {
23
- jti: string
24
- ati: string
25
- iat: number
26
- aud: string
27
- sub: string
28
- exp: number // exp is in seconds
29
- scopes: Scope[]
30
- }
31
-
32
- export type AccessToken = RefreshToken
33
-
34
- export type RawTokens = { idToken: string; refreshToken: string; accessToken: string }
35
-
36
- export type Scope =
37
- | 'user.basic.read'
38
- | 'user.establishment.read'
39
- | 'user.booking.read'
40
- | 'user.clients.read'
41
- | 'user.clients.delete'
42
-
43
- export const tokenFromJwt = <T>(token: string): T => JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString())
44
-
45
- export const oauthClientToken = (clientId: string, clientSecret: string): string => {
46
- const token = Buffer.from(`${clientId}:${clientSecret}`).toString('base64')
47
- return `Basic ${token}`
48
- }
49
-
50
- export type TokenFetcher = (refreshToken: string, tokenUrl: string, authorizationHeader: string) => Promise<RawTokens>
51
-
52
- export const tokenFetcher: TokenFetcher = (userRefreshToken, tokenUrl, authorizationHeader) => {
53
- return new Promise<RawTokens>((resolve, reject) => {
54
- superagent
55
- .post(tokenUrl)
56
- .set('Content-Type', 'application/x-www-form-urlencoded')
57
- .set('Authorization', authorizationHeader)
58
- .query({ refresh_token: userRefreshToken, grant_type: 'refresh_token' })
59
- .end((err, res) => {
60
- if (err) reject(err)
61
- const { id_token: idToken, access_token: accessToken, refresh_token: refreshToken } = res.body
62
- return resolve({ idToken, accessToken, refreshToken })
63
- })
64
- })
65
- }
@@ -1,154 +0,0 @@
1
- diff --git a/server/app.ts b/server/app.ts
2
- index eddaa67..26fb35e 100644
3
- --- a/server/app.ts
4
- +++ b/server/app.ts
5
- @@ -5,11 +5,9 @@ import createError from 'http-errors'
6
- import nunjucksSetup from './utils/nunjucksSetup'
7
- import errorHandler from './errorHandler'
8
- import { appInsightsMiddleware } from './utils/azureAppInsights'
9
- -import authorisationMiddleware from './middleware/authorisationMiddleware'
10
-
11
- import setUpAuthentication from './middleware/setUpAuthentication'
12
- import setUpCsrf from './middleware/setUpCsrf'
13
- -import setUpCurrentUser from './middleware/setUpCurrentUser'
14
- import setUpHealthChecks from './middleware/setUpHealthChecks'
15
- import setUpStaticResources from './middleware/setUpStaticResources'
16
- import setUpWebRequestParsing from './middleware/setupRequestParsing'
17
- @@ -34,9 +32,7 @@ export default function createApp(services: Services): express.Application {
18
- app.use(setUpStaticResources())
19
- nunjucksSetup(app)
20
- app.use(setUpAuthentication())
21
- - app.use(authorisationMiddleware())
22
- app.use(setUpCsrf())
23
- - app.use(setUpCurrentUser())
24
-
25
- app.use(routes(services))
26
-
27
- diff --git a/server/interfaces/hmppsUser.ts b/server/interfaces/hmppsUser.ts
28
- index f0a6dd2..8c01550 100644
29
- --- a/server/interfaces/hmppsUser.ts
30
- +++ b/server/interfaces/hmppsUser.ts
31
- @@ -1,4 +1,6 @@
32
- -export type AuthSource = 'nomis' | 'delius' | 'external' | 'azuread'
33
- +import { LaunchpadUser } from '@ministryofjustice/hmpps-prisoner-auth'
34
- +
35
- +export type AuthSource = 'nomis' | 'delius' | 'external' | 'azuread' | 'prisoner-auth'
36
-
37
- /**
38
- * These are the details that all user types share.
39
- @@ -57,4 +59,4 @@ export interface AzureADUser extends BaseUser {
40
- authSource: 'azuread'
41
- }
42
-
43
- -export type HmppsUser = PrisonUser | ProbationUser | ExternalUser | AzureADUser
44
- +export type HmppsUser = PrisonUser | ProbationUser | ExternalUser | AzureADUser | LaunchpadUser
45
- diff --git a/server/middleware/setUpAuthentication.ts b/server/middleware/setUpAuthentication.ts
46
- index 1979d6e..a954198 100644
47
- --- a/server/middleware/setUpAuthentication.ts
48
- +++ b/server/middleware/setUpAuthentication.ts
49
- @@ -1,12 +1,8 @@
50
- import passport from 'passport'
51
- import flash from 'connect-flash'
52
- import { Router } from 'express'
53
- -import { Strategy } from 'passport-oauth2'
54
- -import { VerificationClient, AuthenticatedRequest } from '@ministryofjustice/hmpps-auth-clients'
55
- -import config from '../config'
56
- +import { LaunchpadUser, PrisonerAuth, minutes } from '@ministryofjustice/hmpps-prisoner-auth'
57
- import { HmppsUser } from '../interfaces/hmppsUser'
58
- -import generateOauthClientToken from '../utils/clientCredentials'
59
- -import logger from '../../logger'
60
-
61
- passport.serializeUser((user, done) => {
62
- // Not used but required for Passport
63
- @@ -18,26 +14,17 @@ passport.deserializeUser((user, done) => {
64
- done(null, user as Express.User)
65
- })
66
-
67
- -passport.use(
68
- - new Strategy(
69
- - {
70
- - authorizationURL: `${config.apis.hmppsAuth.externalUrl}/oauth/authorize`,
71
- - tokenURL: `${config.apis.hmppsAuth.url}/oauth/token`,
72
- - clientID: config.apis.hmppsAuth.authClientId,
73
- - clientSecret: config.apis.hmppsAuth.authClientSecret,
74
- - callbackURL: `${config.ingressUrl}/sign-in/callback`,
75
- - state: true,
76
- - customHeaders: { Authorization: generateOauthClientToken() },
77
- - },
78
- - (token, refreshToken, params, profile, done) => {
79
- - return done(null, { token, username: params.user_name, authSource: params.auth_source })
80
- - },
81
- - ),
82
- -)
83
- +const prisonerAuth = new PrisonerAuth({
84
- + launchpadAuthUrl: 'VALUE_HERE',
85
- + clientId: 'VALUE_HERE',
86
- + clientSecret: 'VALUE_HERE',
87
- + tokenMinimumLifespan: minutes(5),
88
- +})
89
- +
90
- +passport.use('prisoner-auth', prisonerAuth.passportStrategy())
91
-
92
- export default function setupAuthentication() {
93
- const router = Router()
94
- - const tokenVerificationClient = new VerificationClient(config.apis.tokenVerification, logger)
95
-
96
- router.use(passport.initialize())
97
- router.use(passport.session())
98
- @@ -48,40 +35,39 @@ export default function setupAuthentication() {
99
- return res.render('autherror')
100
- })
101
-
102
- - router.get('/sign-in', passport.authenticate('oauth2'))
103
- + router.get('/sign-in', passport.authenticate('prisoner-auth'))
104
-
105
- router.get('/sign-in/callback', (req, res, next) =>
106
- - passport.authenticate('oauth2', {
107
- + passport.authenticate('prisoner-auth', {
108
- successReturnToOrRedirect: req.session.returnTo || '/',
109
- failureRedirect: '/autherror',
110
- })(req, res, next),
111
- )
112
-
113
- - const authUrl = config.apis.hmppsAuth.externalUrl
114
- - const authParameters = `client_id=${config.apis.hmppsAuth.authClientId}&redirect_uri=${config.ingressUrl}`
115
- -
116
- router.use('/sign-out', (req, res, next) => {
117
- - const authSignOutUrl = `${authUrl}/sign-out?${authParameters}`
118
- if (req.user) {
119
- req.logout(err => {
120
- if (err) return next(err)
121
- - return req.session.destroy(() => res.redirect(authSignOutUrl))
122
- + return req.session.destroy(() => res.redirect('/'))
123
- })
124
- - } else res.redirect(authSignOutUrl)
125
- - })
126
- -
127
- - router.use('/account-details', (req, res) => {
128
- - res.redirect(`${authUrl}/account-details?${authParameters}`)
129
- + } else res.redirect('/')
130
- })
131
-
132
- router.use(async (req, res, next) => {
133
- - if (req.isAuthenticated() && (await tokenVerificationClient.verifyToken(req as unknown as AuthenticatedRequest))) {
134
- - return next()
135
- + if (!req.isAuthenticated()) {
136
- + req.session.returnTo = req.originalUrl
137
- + return res.redirect('/sign-in')
138
- }
139
- - req.session.returnTo = req.originalUrl
140
- - return res.redirect('/sign-in')
141
- - })
142
-
143
- + return prisonerAuth
144
- + .validateAndRefreshUser(req.user as LaunchpadUser)
145
- + .then(user => {
146
- + req.user = user
147
- + next()
148
- + })
149
- + .catch(() => res.redirect('/autherror'))
150
- + })
151
- +
152
- router.use((req, res, next) => {
153
- res.locals.user = req.user as HmppsUser
154
- next()