mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-15 00:52:09 +00:00
Pass Okta SCIM 2.0 SPEC Test
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user