Pass Okta SCIM 2.0 SPEC Test

This commit is contained in:
Tuan Dang
2024-02-14 09:22:23 -08:00
parent 3a7b697549
commit c73ee49425
29 changed files with 1098 additions and 136 deletions
+193 -42
View File
@@ -1,10 +1,13 @@
import jwt from "jsonwebtoken";
import { z } from "zod";
import { ScimTokensSchema } from "@app/db/schemas";
import { getConfig } from "@app/lib/config/env";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode, AuthTokenType } from "@app/services/auth/auth-type";
export const registerScimRouter = async (server: FastifyZodProvider) => {
server.route({
url: "/",
@@ -27,29 +30,177 @@ export const registerScimRouter = async (server: FastifyZodProvider) => {
url: "/Users",
method: "GET",
schema: {
params: z.object({}),
querystring: z.object({
startIndex: z.coerce.number().default(1),
count: z.coerce.number().default(20),
filter: z.string().trim().optional()
}),
response: {
200: z.object({})
200: z.object({ // TODO: audit the response
Resources: z.array(z.object({
id: z.string().trim(),
userName: z.string().trim(),
name: z.object({
familyName: z.string().trim(),
givenName: z.string().trim()
}),
emails: z.array(z.object({
primary: z.boolean(),
value: z.string().email(),
type: z.string().trim()
})),
displayName: z.string().trim(),
active: z.boolean()
})),
itemsPerPage: z.number(),
schemas: z.array(z.string()),
startIndex: z.number(),
totalResults: z.number(),
})
}
},
// onRequest: verifyAuth([]),
handler: async () => {
return {
hello: "world"
};
onRequest: verifyAuth([AuthMode.SCIM_TOKEN]),
handler: async (req) => {
const res = await req.server.services.scim.listUsers({
offset: req.query.startIndex,
limit: req.query.count,
filter: req.query.filter
});
return res;
}
});
server.route({
url: "/tokens/organizations/:organizationId", // api/v1/scim/token/organizations/:organizationId
url: "/Users/:userId",
method: "GET",
schema: {
params: z.object({
userId: z.string().trim()
}),
response: {
201: z.object({
schemas: z.array(z.string()),
id: z.string().trim(),
userName: z.string().trim(),
name: z.object({
familyName: z.string().trim(),
givenName: z.string().trim()
}),
emails: z.array(z.object({
primary: z.boolean(),
value: z.string().email(),
type: z.string().trim()
})),
displayName: z.string().trim(),
active: z.boolean()
})
}
},
onRequest: verifyAuth([AuthMode.SCIM_TOKEN]),
handler: async (req) => {
const res = await req.server.services.scim.getUser(req.params.userId);
return res;
}
});
server.route({
url: "/Users",
method: "POST",
schema: {
body: z.object({
schemas: z.array(z.string()),
userName: z.string().trim(),
name: z.object({
familyName: z.string().trim(),
givenName: z.string().trim()
}),
emails: z.array(z.object({
primary: z.boolean(),
value: z.string().email(),
type: z.string().trim()
})),
displayName: z.string().trim(),
// locale: z.string().trim(),
// externalId: z.string().trim(),
// groups: z.array(z.object({
// value: z.string().trim()
// })),
// password: z.string().trim(),
active: z.boolean()
}),
response: {
200: z.object({
schemas: z.array(z.string()),
id: z.string().trim(),
userName: z.string().trim(),
name: z.object({
familyName: z.string().trim(),
givenName: z.string().trim()
}),
emails: z.array(z.object({
primary: z.boolean(),
value: z.string().email(),
type: z.string().trim()
})),
displayName: z.string().trim(),
active: z.boolean()
})
}
},
onRequest: verifyAuth([AuthMode.SCIM_TOKEN]),
handler: async (req, reply) => {
const user = await req.server.services.scim.createUser({
email: req.body.emails[0].value,
firstName: req.body.name.givenName,
lastName: req.body.name.familyName,
orgId: req.permission.orgId as string
});
reply.code(201);
return user;
}
});
server.route({
url: "/Users/:userId",
method: "PATCH",
schema: {
body: z.object({}),
response: {
200: z.object({})
}
},
onRequest: verifyAuth([AuthMode.SCIM_TOKEN]),
handler: async (req) => {
// TODO: update a user's attr
return {};
}
});
server.route({
url: "/Users/:userId",
method: "PUT",
schema: {
body: z.object({}),
response: {
200: z.object({})
}
},
onRequest: verifyAuth([AuthMode.SCIM_TOKEN]),
handler: async (req) => {
// TODO: update a user's profile
return {};
}
});
server.route({
url: "/scim-tokens",
method: "POST",
onRequest: verifyAuth([AuthMode.JWT]),
schema: {
params: z.object({
organizationId: z.string().trim()
}),
body: z.object({
description: z.string().trim(),
organizationId: z.string().trim(),
description: z.string().trim().default(""),
ttl: z.number().min(0).default(0)
}),
response: {
@@ -58,53 +209,53 @@ export const registerScimRouter = async (server: FastifyZodProvider) => {
})
}
},
handler: async () => {
// TODO: create SCIM token logic
// TODO: create SCIM token controller
const appCfg = getConfig();
const scimToken = jwt.sign(
{
authTokenType: AuthTokenType.SCIM_TOKEN
},
appCfg.AUTH_SECRET,
{
// expiresIn: identityAccessToken.accessTokenMaxTTL === 0 ? undefined : identityAccessToken.accessTokenMaxTTL
}
); // TODO: add expiration
handler: async (req) => {
const { scimToken } = await server.services.scim.createScimToken({
organizationId: req.body.organizationId,
description: req.body.description,
ttl: req.body.ttl
});
return { scimToken };
}
});
server.route({
url: "/tokens/organizations/:organizationId", // api/v1/scim/token/organizations/:organizationId
url: "/scim-tokens",
method: "GET",
onRequest: verifyAuth([AuthMode.JWT]),
schema: {
params: z.object({
querystring: z.object({
organizationId: z.string().trim()
}),
response: {
200: z.object({
scimToken: z.string().trim()
scimTokens: z.array(ScimTokensSchema)
})
}
},
handler: async () => {
// TODO: put into service file
const appCfg = getConfig();
const scimToken = jwt.sign(
{
authTokenType: AuthTokenType.SCIM_TOKEN
},
appCfg.AUTH_SECRET,
{
// expiresIn: identityAccessToken.accessTokenMaxTTL === 0 ? undefined : identityAccessToken.accessTokenMaxTTL
}
); // TODO: add expiration
handler: async (req) => {
const scimTokens = await server.services.scim.getScimTokens(req.query.organizationId);
return { scimTokens };
}
});
server.route({
url: "/scim-tokens/:scimTokenId",
method: "DELETE",
onRequest: verifyAuth([AuthMode.JWT]),
schema: {
params: z.object({
scimTokenId: z.string().trim()
}),
response: {
200: z.object({
scimToken: ScimTokensSchema
})
}
},
handler: async (req) => {
const scimToken = await server.services.scim.deleteScimToken(req.params.scimTokenId);
return { scimToken };
}
});
@@ -15,7 +15,7 @@ export type TListProjectAuditLogDTO = {
export type TCreateAuditLogDTO = {
event: Event;
actor: UserActor | IdentityActor | ServiceActor;
actor: UserActor | IdentityActor | ServiceActor | ScimIdpActor;
orgId?: string;
projectId?: string;
} & BaseAuthData;
@@ -120,7 +120,11 @@ export interface IdentityActor {
metadata: IdentityActorMetadata;
}
export type Actor = UserActor | ServiceActor | IdentityActor;
export interface ScimClientActor {
type: ActorType.SCIM_CLIENT;
}
export type Actor = UserActor | ServiceActor | IdentityActor | ScimClientActor;
interface GetSecretsEvent {
type: EventType.GET_SECRETS;
@@ -23,8 +23,8 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({
customAlerts: false,
auditLogs: false,
auditLogsRetentionDays: 0,
samlSSO: false,
scim: false,
samlSSO: true,
scim: true,
status: null,
trial_end: null,
has_used_trial: true,
@@ -24,8 +24,8 @@ export type TFeatureSet = {
customAlerts: false;
auditLogs: false;
auditLogsRetentionDays: 0;
samlSSO: false;
scim: false;
samlSSO: true;
scim: true;
status: null;
trial_end: null;
has_used_trial: true;
+10
View File
@@ -0,0 +1,10 @@
import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
import { ormify } from "@app/lib/knex";
export type TScimDALFactory = ReturnType<typeof scimDALFactory>;
export const scimDALFactory = (db: TDbClient) => {
const scimTokenOrm = ormify(db, TableName.ScimToken);
return scimTokenOrm;
};
@@ -0,0 +1,229 @@
import jwt from "jsonwebtoken";
import {
OrgMembershipRole,
OrgMembershipStatus
} from "@app/db/schemas";
import { TScimDALFactory } from "@app/ee/services/scim/scim-dal";
import { TUserDALFactory } from "@app/services/user/user-dal";
import { TOrgDALFactory } from "@app/services/org/org-dal";
import { TPermissionServiceFactory } from "../permission/permission-service";
import { TLicenseServiceFactory } from "../license/license-service";
import { getConfig } from "@app/lib/config/env";
import { AuthMethod, AuthTokenType } from "@app/services/auth/auth-type";
import {
TCreateScimTokenDTO,
TListScimUsersDTO,
TListScimUsersRes,
TCreateScimUserDTO,
TScimTokenJwtPayload
} from "./scim-types";
import {
createScimUser,
TScimUser,
} from "@app/lib/scim";
import { UnauthorizedError, ScimRequestError } from "@app/lib/errors";
type TScimServiceFactoryDep = {
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
scimDAL: TScimDALFactory; // TODO: pick
userDAL: TUserDALFactory; // TODO: pick
orgDAL: TOrgDALFactory; // TODO: pick
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
};
export type TScimServiceFactory = ReturnType<typeof scimServiceFactory>;
export const scimServiceFactory = ({
licenseService,
scimDAL,
userDAL,
orgDAL,
permissionService
}: TScimServiceFactoryDep) => {
const createScimToken = async ({
organizationId,
description,
ttl
}: TCreateScimTokenDTO) => {
const appCfg = getConfig();
// TODO: permission stuff
const scimTokenData = await scimDAL.create({
orgId: organizationId,
description,
ttl
});
const scimToken = jwt.sign(
{
scimTokenId: scimTokenData.id,
authTokenType: AuthTokenType.SCIM_TOKEN
},
appCfg.AUTH_SECRET
);
return { scimToken }
}
const getScimTokens = async (organizationId: string) => {
const scimTokens = await scimDAL.find({ orgId: organizationId });
return scimTokens;
}
const deleteScimToken = async (scimTokenId: string) => {
const scimToken = await scimDAL.deleteById(scimTokenId);
return scimToken;
}
// scim server endpoints
const listUsers = async ({
offset,
limit,
filter
}: TListScimUsersDTO): Promise<TListScimUsersRes> => {
const parseFilter = (filter: string | undefined) => {
if (!filter) return {};
const [parsedName, parsedValue] = filter.split("eq").map(s => s.trim());
let attributeName = parsedName;
if (parsedName === "userName") { // note
attributeName = "email";
}
return { [attributeName]: parsedValue };
};
const findOpts = {
...(offset && { offset }),
...(limit && { limit }),
};
const users = await userDAL.find(parseFilter(filter), findOpts);
let resources: TScimUser[] = [];
let scimResource: TListScimUsersRes = { // note: type
Resources: [],
itemsPerPage: limit,
schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
startIndex: offset,
totalResults: users.length
};
users.forEach((user) => {
let scimUser = createScimUser({
userId: user.id,
firstName: user.firstName as string,
lastName: user.lastName as string,
email: user.email
});
resources.push(scimUser);
});
scimResource.Resources = resources;
return scimResource;
}
const getUser = async (userId: string) => {
// TODO: check out SCIM-specific errors
let user;
try {
user = await userDAL.findById(userId);
} catch (error) {
interface PostgresError extends Error {
error: {
code: string;
}
}
const dbError = error as PostgresError;
if (dbError.error.code === "22P02") throw new ScimRequestError({
detail: "User not found",
status: 404
});
throw error;
}
if (!user) throw new ScimRequestError({
detail: "User not found",
status: 404
});
return createScimUser({
userId: user.id,
firstName: user.firstName as string,
lastName: user.lastName as string,
email: user.email
});
}
const createUser = async ({
firstName,
lastName,
email,
orgId
}: TCreateScimUserDTO) => {
let user = await userDAL.findOne({
email
});
if (user) throw new ScimRequestError({
detail: "User already exists in the database",
status: 409
});
user = await userDAL.transaction(async (tx) => {
const newUser = await userDAL.create(
{
email,
firstName,
lastName,
authMethods: [AuthMethod.EMAIL]
},
tx
);
await orgDAL.createMembership({
inviteEmail: email,
orgId,
role: OrgMembershipRole.Member,
status: OrgMembershipStatus.Invited
});
return newUser;
});
return createScimUser({
userId: user.id,
firstName: user.firstName as string,
lastName: user.lastName as string,
email: user.email
});
}
const fnValidateScimToken = async (token: TScimTokenJwtPayload) => {
// TODO: check expiry
const scimToken = await scimDAL.findById(token.scimTokenId);
if (!scimToken) throw new UnauthorizedError();
return { scimTokenId: scimToken.id, orgId: scimToken.orgId };
}
return {
createScimToken,
getScimTokens,
deleteScimToken,
listUsers,
getUser,
createUser,
fnValidateScimToken
};
};
@@ -0,0 +1,41 @@
import { TOrgPermission } from "@app/lib/types";
import { TScimUser } from "@app/lib/scim";
export type TCreateScimTokenDTO = {
organizationId: string;
description: string;
ttl: number;
}
// TODO: add org permissions
// & Omit<TOrgPermission, "orgId">;
export type TListScimUsersDTO = {
offset: number;
limit: number;
filter?: string;
}
export type TListScimUsersRes = { // check naming here
schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"];
totalResults: number;
Resources: TScimUser[];
itemsPerPage: number;
startIndex: number;
}
export type TCreateScimUserDTO = {
email: string;
firstName: string;
lastName: string;
orgId: string;
}
export type TCreateScimUserRes = {
schemas: ["urn:ietf:params:scim:schemas:core:2.0:User"]
}
export type TScimTokenJwtPayload = {
scimTokenId: string;
authTokenType: string;
};