Skip to content

Commit 3ee3ffb

Browse files
authored
Merge pull request #6180 from bcgov/chore/5915-2
chore(5915): add user attribute idir_user_guid to keycloak token
2 parents f686149 + f1da3fb commit 3ee3ffb

File tree

14 files changed

+152
-24
lines changed

14 files changed

+152
-24
lines changed

app/app/api/msgraph/search/route.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ export const POST = createApiHandler({
4343
email: true,
4444
upn: true,
4545
idir: true,
46+
idirGuid: true,
4647
officeLocation: true,
4748
jobTitle: true,
4849
image: true,

app/components/modal/userPicker.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ export const openUserPickerModal = createModal<ModalProps, ModalState>({
4848
!user.ministry && 'Your home ministry name is missing',
4949
!user.idir && 'Your IDIR is missing',
5050
!user.upn && 'Your UPN is missing',
51+
!user.idirGuid && 'Your IDIR GUID is missing',
5152
].filter((msg): msg is string => Boolean(msg))
5253
: [];
5354

app/core/api-handler.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ function createApiHandler<
112112
if (!kcUserId) return UnauthorizedResponse("token missing required 'kc-userid' claim");
113113

114114
const kcUser = await findUser(kcUserId);
115+
115116
if (!kcUser) return BadRequestResponse('keycloak user not found');
116117

117118
session = await generateSession({
@@ -122,6 +123,7 @@ function createApiHandler<
122123
userSession: {
123124
email: kcUser.email ?? '',
124125
roles: kcUser.authRoleNames.concat(GlobalRole.ServiceAccount),
126+
idirGuid: kcUser.attributes?.idir_guid,
125127
teams: [],
126128
sub: '',
127129
accessToken: '',
@@ -141,6 +143,7 @@ function createApiHandler<
141143
userSession: {
142144
email: '',
143145
roles: rolesArr.concat(GlobalRole.ServiceAccount),
146+
idirGuid: '',
144147
teams: [],
145148
sub: '',
146149
accessToken: '',

app/core/auth-options.ts

Lines changed: 22 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,22 +9,23 @@ import { JWT } from 'next-auth/jwt';
99
import KeycloakProvider, { KeycloakProfile } from 'next-auth/providers/keycloak';
1010
import { IS_PROD, AUTH_SERVER_URL, AUTH_RELM, AUTH_RESOURCE, AUTH_SECRET, USER_TOKEN_REFRESH_INTERVAL } from '@/config';
1111
import { TEAM_SA_PREFIX, GlobalRole, RoleToSessionProp, sessionRolePropKeys } from '@/constants';
12+
import { logger } from '@/core/logging';
1213
import prisma from '@/core/prisma';
1314
import { EventType, Organization, TaskStatus, UserSession } from '@/prisma/client';
1415
import { createEvent } from '@/services/db';
1516
import { upsertUser } from '@/services/db/user';
16-
1717
interface DecodedToken {
1818
resource_access?: Record<string, { roles: string[] }>;
19+
idir_guid?: string | null;
1920
email: string;
2021
sub: string;
2122
}
22-
23+
type KeycloakProfileWithIdir = KeycloakProfile & { idir_guid?: string };
2324
async function updateUserSession(tokens?: { access_token?: string; refresh_token?: string; id_token?: string }) {
2425
const { access_token = '', refresh_token = '', id_token = '' } = tokens ?? {};
25-
const decodedToken = jwt.decode(access_token) as DecodedToken;
26-
const { resource_access = {}, sub = '', email = '' } = decodedToken || {};
2726

27+
const decodedToken = jwt.decode(access_token) as DecodedToken;
28+
const { resource_access = {}, sub = '', email = '', idir_guid } = decodedToken || {};
2829
const roles = _get(resource_access, `${AUTH_RESOURCE}.roles`, []) as string[];
2930
const teams: SessionTokenTeams[] = [];
3031

@@ -39,6 +40,7 @@ async function updateUserSession(tokens?: { access_token?: string; refresh_token
3940

4041
const userSessionData = {
4142
email: loweremail,
43+
idirGuid: idir_guid,
4244
nextTokenRefreshTime,
4345
roles,
4446
sub,
@@ -148,6 +150,7 @@ export async function generateSession({
148150
name: '',
149151
email: '',
150152
image: '',
153+
idirGuid: '',
151154
};
152155

153156
session.tasks = [];
@@ -156,15 +159,15 @@ export async function generateSession({
156159
const [user, userSession] = await Promise.all([
157160
prisma.user.findFirst({
158161
where: { email: token.email },
159-
select: { id: true, email: true, image: true },
162+
select: { id: true, email: true, image: true, idirGuid: true },
160163
}),
161164
userSessionOverride ??
162165
prisma.userSession.findFirst({
163166
where: { email: token.email },
164167
}),
165168
]);
166-
167169
if (userSession) {
170+
session.user.idirGuid = userSession.idirGuid;
168171
session.idToken = userSession.idToken;
169172
session.kcUserId = userSession.sub;
170173
session.roles = userSession.roles;
@@ -179,7 +182,7 @@ export async function generateSession({
179182
session.user.id = user.id;
180183
session.user.email = user.email;
181184
session.user.image = user.image;
182-
185+
session.user.idirGuid = user?.idirGuid ?? token?.idirGuid ?? session.user.idirGuid;
183186
session.userId = user.id;
184187
session.userEmail = user.email;
185188
session.roles.push(GlobalRole.User);
@@ -397,8 +400,14 @@ export const authOptions: AuthOptions = {
397400
secret: AUTH_SECRET,
398401
callbacks: {
399402
async signIn({ user, account, profile }) {
400-
const { given_name, family_name, email } = profile as KeycloakProfile;
403+
const { given_name, family_name, email, idir_guid } = profile as KeycloakProfileWithIdir;
404+
405+
if (!idir_guid) {
406+
logger.warn(`Login blocked: Missing idirGuid for user ${user?.email}`);
407+
return false;
408+
}
401409
const loweremail = email.toLowerCase();
410+
402411
const lastSeen = new Date();
403412

404413
const upsertedUser = await upsertUser(loweremail, { lastSeen });
@@ -410,14 +419,13 @@ export const authOptions: AuthOptions = {
410419
email: loweremail,
411420
ministry: '',
412421
idir: '',
413-
idirGuid: '',
422+
idirGuid: idir_guid,
414423
upn: '',
415424
image: '',
416425
officeLocation: '',
417426
jobTitle: '',
418427
lastSeen,
419428
};
420-
421429
await prisma.user.upsert({
422430
where: { email: loweremail },
423431
update: data,
@@ -429,7 +437,8 @@ export const authOptions: AuthOptions = {
429437
},
430438
async jwt({ token, account }: { token: JWT; account: Account | null }) {
431439
if (account) {
432-
await updateUserSession(account);
440+
const updatedSession = await updateUserSession(account);
441+
if (updatedSession.idirGuid) token.idirGuid = updatedSession.idirGuid;
433442
return token;
434443
}
435444

@@ -441,7 +450,8 @@ export const authOptions: AuthOptions = {
441450
if (userSessToRefresh) {
442451
const newTokens = await getNewTokens(userSessToRefresh.refreshToken);
443452
if (newTokens) {
444-
await updateUserSession(newTokens);
453+
const refreshedSession = await updateUserSession(newTokens);
454+
if (refreshedSession.idirGuid) token.idirGuid = refreshedSession.idirGuid;
445455
} else {
446456
await endUserSession(token.email);
447457
}
@@ -454,7 +464,6 @@ export const authOptions: AuthOptions = {
454464
events: {
455465
async signIn({ user, account }: { user: User; account: Account | null }) {
456466
if (!user?.email) return;
457-
458467
const loweremail = user.email.toLowerCase();
459468
const loggedInUser = await prisma.user.findUnique({
460469
where: { email: loweremail },

app/helpers/mock-users.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ export async function generateTestSession(testEmail: string) {
8686
userSession: {
8787
email: mockUser.email,
8888
roles: mockUser.roles,
89+
idirGuid: mockUser.idirGuid,
8990
teams: [],
9091
sub: '',
9192
accessToken: '',

app/prisma/schema.prisma

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ model UserSession {
6464
id String @id @default(auto()) @map("_id") @db.ObjectId
6565
email String @unique
6666
nextTokenRefreshTime DateTime
67+
idirGuid String?
6768
accessToken String
6869
refreshToken String
6970
idToken String

app/types/next-auth.d.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ declare module 'next-auth' {
7575
name: string;
7676
email: string;
7777
image: string | null;
78+
idirGuid: string | null;
7879
};
7980
userId: string | null;
8081
userEmail: string | null;
@@ -139,12 +140,17 @@ declare module 'next-auth' {
139140
clientId: string;
140141
roles: string[];
141142
}
143+
144+
interface User {
145+
idirGuid?: string | null;
146+
}
142147
}
143148

144149
declare module 'next-auth/jwt' {
145150
/** Returned by the `jwt` callback and `getToken`, when using JWT sessions */
146151
interface JWT {
147152
email?: string;
153+
idirGuid?: string;
148154
}
149155
}
150156

app/types/user.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ export type SearchedUser = Prisma.UserGetPayload<{
121121
email: true;
122122
upn: true;
123123
idir: true;
124+
idirGuid: true;
124125
officeLocation: true;
125126
jobTitle: true;
126127
image: true;

sandbox/_packages/keycloak-admin/src/main.ts

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ interface KcUser {
3030
lastName: string;
3131
password: string;
3232
roles: string[];
33+
attributes: Record<string, string[]>;
3334
}
3435

3536
// Keycloak Admin class to manage Keycloak administration tasks
@@ -213,6 +214,20 @@ export class KcAdmin {
213214
return scope;
214215
}
215216

217+
async updateUser(realm: string, userId: string, kcuser: KcUser) {
218+
const { password, roles, ...rest } = kcuser;
219+
220+
await this._cli.users.update({ realm, id: userId }, rest);
221+
222+
if (password) {
223+
await this._cli.users.resetPassword({
224+
realm,
225+
id: userId,
226+
credential: { temporary: false, type: 'password', value: password },
227+
});
228+
}
229+
}
230+
216231
// Find a user by email in a realm
217232
async findUserByEmail(realm: string, email: string) {
218233
const emailUsers = await this._cli.users.find({ realm, email, exact: true });
@@ -222,7 +237,7 @@ export class KcAdmin {
222237

223238
// Create a new user in a realm
224239
async createUser(realm: string, kcuser: KcUser) {
225-
const { password, roles, ...rest } = kcuser;
240+
const { password, roles, attributes, ...rest } = kcuser;
226241

227242
// Catch an error if the user already exists
228243
try {
@@ -231,6 +246,7 @@ export class KcAdmin {
231246
enabled: true,
232247
realm,
233248
emailVerified: true,
249+
attributes,
234250
});
235251

236252
await this._cli.users.resetPassword({
@@ -284,11 +300,19 @@ export class KcAdmin {
284300
});
285301

286302
await Promise.all(
287-
users.map(async ({ email: _email, username, firstName, lastName, password, roles: _roles }) => {
303+
users.map(async ({ email: _email, username, firstName, lastName, password, roles: _roles, attributes }) => {
288304
const email = _email.toLowerCase();
289305
const roles = uniq(castArray(_roles || [])).filter(Boolean);
290306

291-
const currUser = await this.createUser(realm, { email, username, firstName, lastName, password, roles });
307+
const currUser = await this.createUser(realm, {
308+
email,
309+
username,
310+
firstName,
311+
lastName,
312+
password,
313+
roles,
314+
attributes,
315+
});
292316

293317
// Revoke all client roles from the user
294318
await this._cli.users.delClientRoleMappings({

sandbox/keycloak-provision/main.ts

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,24 @@ function getMapperPayload(name: string, claimValue: string) {
5050
return mapper;
5151
}
5252

53+
function getUserAttrMapperPayload(userAttr: string, claimName: string) {
54+
return {
55+
name: claimName,
56+
protocol: 'openid-connect',
57+
protocolMapper: 'oidc-usermodel-attribute-mapper',
58+
config: {
59+
'user.attribute': userAttr,
60+
'claim.name': claimName,
61+
'jsonType.label': 'String',
62+
'id.token.claim': 'true',
63+
'access.token.claim': 'true',
64+
'userinfo.token.claim': 'true',
65+
'access.tokenResponse.claim': 'false',
66+
multivalued: 'false',
67+
},
68+
};
69+
}
70+
5371
async function main() {
5472
console.log('Starting Keycloak Provision...');
5573

@@ -84,6 +102,11 @@ async function main() {
84102
const authRealm = await kc.upsertRealm(AUTH_REALM_NAME, { enabled: true });
85103
const authClient = await kc.createPrivateClient(AUTH_REALM_NAME, AUTH_CLIENT_ID, AUTH_CLIENT_SECRET);
86104

105+
await kc.cli.clients.addProtocolMapper(
106+
{ realm: AUTH_REALM_NAME, id: authClient?.id as string },
107+
getUserAttrMapperPayload('idir_user_guid', 'idir_guid'),
108+
);
109+
87110
const scope = await kc.createRealmClientScope(AUTH_REALM_NAME, clientScope);
88111

89112
kc.cli.clients.addDefaultClientScope({
@@ -140,14 +163,19 @@ async function main() {
140163
// Upsert Admin client
141164
await kc.createRealmAdminServiceAccount(AUTH_REALM_NAME, ADMIN_CLIENT_ID, ADMIN_CLIENT_SECRET);
142165

143-
const authUsers = msUsers.map(({ surname, givenName, mail, jobTitle }) => ({
144-
username: mail,
145-
email: mail,
146-
firstName: givenName,
147-
lastName: surname,
148-
password: mail,
149-
roles: jobTitle ? jobTitle.split(',').map((role) => role.trim()) : [],
150-
}));
166+
const authUsers = msUsers.map(
167+
({ surname, givenName, mail, jobTitle, extension_85cc52e9286540fcb1f97ed86114a0e5_bcgovGUID }) => ({
168+
username: mail,
169+
email: mail,
170+
firstName: givenName,
171+
lastName: surname,
172+
password: mail,
173+
roles: jobTitle ? jobTitle.split(',').map((role) => role.trim()) : [],
174+
attributes: {
175+
idir_user_guid: [extension_85cc52e9286540fcb1f97ed86114a0e5_bcgovGUID],
176+
},
177+
}),
178+
);
151179

152180
// Create Auth Users with auth roles assigned
153181
await kc.upsertUsersWithClientRoles(AUTH_REALM_NAME, authClient?.id as string, authUsers);

0 commit comments

Comments
 (0)