Skip to content

Commit ce763c0

Browse files
authored
Release v1.50.0 (#1141)
feat: SSO login
2 parents 8f8b04f + 88560ce commit ce763c0

File tree

33 files changed

+531
-61
lines changed

33 files changed

+531
-61
lines changed

ecs/env.json

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,18 @@
245245
{
246246
"name": "TILES_POSTGRES_PORT",
247247
"valueFrom": "plumber-<ENVIRONMENT>-tiles-rds-port"
248+
},
249+
{
250+
"name": "SSO_CLIENT_ID",
251+
"valueFrom": "plumber-<ENVIRONMENT>-sso-client-id"
252+
},
253+
{
254+
"name": "SSO_CLIENT_SECRET",
255+
"valueFrom": "plumber-<ENVIRONMENT>-sso-client-secret"
256+
},
257+
{
258+
"name": "SSO_DISCOVERY_URL",
259+
"valueFrom": "plumber-<ENVIRONMENT>-sso-discovery-url"
248260
}
249261
]
250-
}
262+
}

package-lock.json

Lines changed: 4 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/backend/.env-example

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,4 +61,9 @@ M365_LOCAL_DEV_CLIENT_ID=...
6161
M365_LOCAL_DEV_CLIENT_THUMBPRINT=...
6262
M365_LOCAL_DEV_CLIENT_PRIVATE_KEY=...
6363
M365_LOCAL_DEV_ALLOWED_SENSITIVITY_LABEL_GUIDS_CSV=11111111-1111-1111-1111-111111111111
64-
M365_EXCEL_INTERVAL_BETWEEN_ACTIONS_MS=1000
64+
M365_EXCEL_INTERVAL_BETWEEN_ACTIONS_MS=1000
65+
66+
# SSO LOGIN
67+
SSO_CLIENT_ID=...
68+
SSO_CLIENT_SECRET=...
69+
SSO_DISCOVERY_URL=...

packages/backend/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@
7272
"multer": "2.0.2",
7373
"nanoid": "3.3.8",
7474
"objection": "^3.0.0",
75+
"openid-client": "5.4.0",
7576
"p-limit": "3.1.0",
7677
"pg": "^8.7.1",
7778
"pg-query-stream": "4.9.6",
@@ -108,5 +109,5 @@
108109
"tsconfig-paths": "^4.2.0",
109110
"type-fest": "4.10.3"
110111
},
111-
"version": "1.49.0"
112+
"version": "1.50.0"
112113
}

packages/backend/src/config/app.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,11 @@ type AppConfig = {
5555
database: string
5656
enableSsl: boolean
5757
}
58+
sso: {
59+
clientId: string
60+
clientSecret: string
61+
discoveryUrl: string
62+
}
5863
}
5964

6065
const port = process.env.PORT || '3000'
@@ -124,6 +129,11 @@ const appConfig: AppConfig = {
124129
database: process.env.TILES_POSTGRES_DATABASE || 'plumber_tiles_dev',
125130
enableSsl: process.env.TILES_POSTGRES_ENABLE_SSL === 'true',
126131
},
132+
sso: {
133+
clientId: process.env.SSO_CLIENT_ID,
134+
clientSecret: process.env.SSO_CLIENT_SECRET,
135+
discoveryUrl: process.env.SSO_DISCOVERY_URL,
136+
},
127137
}
128138

129139
if (!appConfig.encryptionKey) {
@@ -151,6 +161,14 @@ if (
151161
throw new Error('Sgid environment variables need to be set!')
152162
}
153163

164+
if (
165+
!appConfig.sso.clientId ||
166+
!appConfig.sso.clientSecret ||
167+
!appConfig.sso.discoveryUrl
168+
) {
169+
throw new Error('SSO environment variables need to be set!')
170+
}
171+
154172
if (!appConfig.launchDarklySdkKey) {
155173
throw new Error('LAUNCH_DARKLY_SDK_KEY environment variable needs to be set!')
156174
}

packages/backend/src/graphql/mutation-resolvers.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import generateAuthUrl from './mutations/generate-auth-url'
1616
import generatePresignedUrl from './mutations/generate-presigned-url'
1717
import loginWithSelectedSgid from './mutations/login-with-selected-sgid'
1818
import loginWithSgid from './mutations/login-with-sgid'
19+
import loginWithSso from './mutations/login-with-sso'
1920
import logout from './mutations/logout'
2021
import registerConnection from './mutations/register-connection'
2122
import requestOtp from './mutations/request-otp'
@@ -76,6 +77,7 @@ export default {
7677
logout,
7778
loginWithSgid,
7879
loginWithSelectedSgid,
80+
loginWithSso,
7981
createFlowTransfer,
8082
updateFlowTransferStatus,
8183
duplicateFlow,
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import {
2+
getOrCreateUser,
3+
sendOnboardingEmail,
4+
setAuthCookie,
5+
updateLastLogin,
6+
} from '@/helpers/auth'
7+
import { getLdFlagValue } from '@/helpers/launch-darkly'
8+
import logger from '@/helpers/logger'
9+
import { ssoClient } from '@/helpers/sso-client'
10+
11+
import type { MutationResolvers } from '../__generated__/types.generated'
12+
13+
const loginWithSso: MutationResolvers['loginWithSso'] = async (
14+
_parent,
15+
params,
16+
context,
17+
) => {
18+
const { authCode, nonce, verifier } = params.input
19+
20+
const ssoEnabled = await getLdFlagValue<boolean>(
21+
'ogp-sso-enabled',
22+
null,
23+
false,
24+
)
25+
26+
if (!ssoEnabled) {
27+
throw new Error('SSO is not enabled')
28+
}
29+
30+
try {
31+
const { accessToken, sub } = await ssoClient.callback({
32+
code: authCode,
33+
nonce,
34+
codeVerifier: verifier,
35+
})
36+
const userInfo = await ssoClient.userinfo({
37+
accessToken,
38+
sub,
39+
})
40+
41+
if (!userInfo) {
42+
throw new Error('Received nullish user info')
43+
}
44+
45+
const userEmail = userInfo.email.toLowerCase().trim()
46+
47+
// TODO: Remove this once it's public release
48+
if (!userEmail.endsWith('@open.gov.sg')) {
49+
throw new Error('Only OGP officers are allowed to login with SSO')
50+
}
51+
52+
const user = await getOrCreateUser(userEmail)
53+
await sendOnboardingEmail(user)
54+
await updateLastLogin(user.id)
55+
setAuthCookie(context.res, { userId: user.id, isSso: true })
56+
} catch (error) {
57+
// Small log event to make it easier to get pulse on sgid error rate.
58+
logger.error('SSO: Unable to query user info', {
59+
event: 'sso-login-failed-user-info',
60+
})
61+
62+
throw error
63+
}
64+
65+
return true
66+
}
67+
68+
export default loginWithSso
Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { deleteAuthCookie } from '@/helpers/auth'
1+
import { deleteAuthCookie, getParsedAuthCookie } from '@/helpers/auth'
22

33
import type { MutationResolvers } from '../__generated__/types.generated'
44

@@ -7,8 +7,11 @@ const logout: MutationResolvers['logout'] = async (
77
_params,
88
context,
99
) => {
10+
const { isSso } = getParsedAuthCookie(context.req)
1011
deleteAuthCookie(context.res)
11-
return true
12+
return {
13+
isSso,
14+
}
1215
}
1316

1417
export default logout

packages/backend/src/graphql/schema.graphql

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -91,8 +91,9 @@ type Mutation {
9191
bulkRetryIterations(
9292
input: BulkRetryIterationsInput
9393
): BulkRetryIterationsResult!
94-
logout: Boolean
95-
loginWithSgid(input: LoginWithSgidInput!): LoginWithSgidResult!
94+
logout: LogoutResult
95+
loginWithSgid(input: OidcLoginInput!): LoginWithSgidResult!
96+
loginWithSso(input: OidcLoginInput!): Boolean!
9697
loginWithSelectedSgid(
9798
input: LoginWithSelectedSgidInput!
9899
): LoginWithSelectedSgidResult!
@@ -895,7 +896,7 @@ type SgidPublicOfficerEmployment {
895896
employmentTitle: String
896897
}
897898

898-
input LoginWithSgidInput {
899+
input OidcLoginInput {
899900
authCode: String!
900901
nonce: String!
901902
verifier: String!
@@ -913,6 +914,10 @@ type LoginWithSelectedSgidResult {
913914
success: Boolean!
914915
}
915916

917+
type LogoutResult {
918+
isSso: Boolean
919+
}
920+
916921
# End of SGID types
917922
# Start of flow transfers types
918923

packages/backend/src/helpers/auth.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,12 @@ const AUTH_COOKIE_NAME = 'plumber.sid'
1212
const TOKEN_EXPIRES_IN_SEC = 3 * 24 * 60 * 60
1313
const ONBOARDING_EMAIL_RELEASE_DATE = new Date('2025-03-10')
1414

15-
export function setAuthCookie(
16-
res: Response,
17-
payload: { userId: string },
18-
): void {
15+
interface AuthCookiePayload {
16+
userId: string
17+
isSso?: boolean
18+
}
19+
20+
export function setAuthCookie(res: Response, payload: AuthCookiePayload): void {
1921
// create jwt
2022
const token = jwt.sign(payload, appConfig.sessionSecretKey, {
2123
expiresIn: TOKEN_EXPIRES_IN_SEC,
@@ -34,6 +36,17 @@ function getAuthCookie(req: Request) {
3436
return req.cookies[AUTH_COOKIE_NAME]
3537
}
3638

39+
export function getParsedAuthCookie(req: Request) {
40+
const token = getAuthCookie(req)
41+
if (!token) {
42+
return null
43+
}
44+
return jwt.verify(token, appConfig.sessionSecretKey) as {
45+
userId: string
46+
isSso?: boolean
47+
}
48+
}
49+
3750
export async function getLoggedInUser(req: Request): Promise<User | null> {
3851
const token = getAuthCookie(req)
3952
if (!token) {

0 commit comments

Comments
 (0)