forked from baron/baron-sso
feat(user): support fixed UUID registration and enhance bulk import results
- Added support for fixed UUIDs during bulk registration (Search-first + ExternalID mapping) - Implemented idempotency and visibility restoration for soft-deleted users - Enhanced bulk upload UI to show 'New/Updated/Unchanged' status and modified fields - Added logic to reclaim identifiers (login_id) from colliding records - Added frontend E2E and backend unit tests for UUID integrity and conflict handling - Fixed i18n, formatting, and mock tests to satisfy code-check - Applied 'go fix' for 'omitzero' tags and general Go standards
This commit is contained in:
@@ -127,9 +127,9 @@ function hanmacEmailStatusClass(preview?: HanmacImportEmailPreview) {
|
|||||||
|
|
||||||
export const downloadUserTemplate = () => {
|
export const downloadUserTemplate = () => {
|
||||||
const headers =
|
const headers =
|
||||||
"email,sub_email,name,phone,role,tenant_slug,department,grade,position,jobTitle,employee_id,tenant_slug1,department1,grade1,position1,jobTitle1,employee_id1";
|
"uuid,email,sub_email,name,phone,role,tenant_slug,department,grade,position,jobTitle,employee_id,tenant_slug1,department1,grade1,position1,jobTitle1,employee_id1";
|
||||||
const example =
|
const example =
|
||||||
"user1@example.com,sub1@test.com;sub2@test.com,홍길동,010-1234-5678,user,tenant-slug,개발팀,수석,팀장,프론트엔드,EMP001,second-tenant,센터,책임,,Architecture,EMP002";
|
",user1@example.com,sub1@test.com;sub2@test.com,홍길동,010-1234-5678,user,tenant-slug,개발팀,수석,팀장,프론트엔드,EMP001,second-tenant,센터,책임,,Architecture,EMP002";
|
||||||
const blob = new Blob([`${headers}\n${example}`], {
|
const blob = new Blob([`${headers}\n${example}`], {
|
||||||
type: "text/csv;charset=utf-8;",
|
type: "text/csv;charset=utf-8;",
|
||||||
});
|
});
|
||||||
@@ -295,22 +295,6 @@ export function UserBulkUploadModal({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const downloadTemplate = () => {
|
|
||||||
const headers =
|
|
||||||
"email,sub_email,name,phone,role,tenant_slug,department,grade,position,jobTitle,employee_id,tenant_slug1,department1,grade1,position1,jobTitle1,employee_id1";
|
|
||||||
const example =
|
|
||||||
"user1@example.com,sub1@test.com;sub2@test.com,홍길동,010-1234-5678,user,tenant-slug,개발팀,수석,팀장,프론트엔드,EMP001,second-tenant,센터,책임,,Architecture,EMP002";
|
|
||||||
const blob = new Blob([`${headers}\n${example}`], {
|
|
||||||
type: "text/csv;charset=utf-8;",
|
|
||||||
});
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement("a");
|
|
||||||
a.href = url;
|
|
||||||
a.download = "user_bulk_template.csv";
|
|
||||||
a.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
};
|
|
||||||
|
|
||||||
const reset = () => {
|
const reset = () => {
|
||||||
setFile(null);
|
setFile(null);
|
||||||
setPreviewData([]);
|
setPreviewData([]);
|
||||||
@@ -410,7 +394,7 @@ export function UserBulkUploadModal({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={downloadTemplate}
|
onClick={downloadUserTemplate}
|
||||||
className="gap-2"
|
className="gap-2"
|
||||||
>
|
>
|
||||||
<Download size={14} />
|
<Download size={14} />
|
||||||
@@ -605,15 +589,71 @@ export function UserBulkUploadModal({
|
|||||||
) : (
|
) : (
|
||||||
<div className="space-y-4 py-4">
|
<div className="space-y-4 py-4">
|
||||||
<div className="flex items-center gap-4 p-4 rounded-lg bg-muted/30 border">
|
<div className="flex items-center gap-4 p-4 rounded-lg bg-muted/30 border">
|
||||||
<div className="flex-1 text-center">
|
{results.some((r) => r.success && r.status === "created") && (
|
||||||
<div className="text-2xl font-bold text-green-600">
|
<>
|
||||||
{successCount}
|
<div className="flex-1 text-center">
|
||||||
</div>
|
<div className="text-2xl font-bold text-green-600">
|
||||||
<div className="text-xs text-muted-foreground uppercase">
|
{
|
||||||
{t("ui.common.success", "성공")}
|
results.filter(
|
||||||
</div>
|
(r) => r.success && r.status === "created",
|
||||||
</div>
|
).length
|
||||||
<div className="w-px h-10 bg-border" />
|
}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground uppercase">
|
||||||
|
{t("ui.common.status.new", "신규")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-px h-10 bg-border" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{results.some((r) => r.success && r.status === "updated") && (
|
||||||
|
<>
|
||||||
|
<div className="flex-1 text-center">
|
||||||
|
<div className="text-2xl font-bold text-blue-600">
|
||||||
|
{
|
||||||
|
results.filter(
|
||||||
|
(r) => r.success && r.status === "updated",
|
||||||
|
).length
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground uppercase">
|
||||||
|
{t("ui.common.status.updated", "수정")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-px h-10 bg-border" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{results.some((r) => r.success && r.status === "unchanged") && (
|
||||||
|
<>
|
||||||
|
<div className="flex-1 text-center">
|
||||||
|
<div className="text-2xl font-bold text-slate-500">
|
||||||
|
{
|
||||||
|
results.filter(
|
||||||
|
(r) => r.success && r.status === "unchanged",
|
||||||
|
).length
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground uppercase">
|
||||||
|
{t("ui.common.status.unchanged", "동일")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-px h-10 bg-border" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!results.some((r) => r.success && r.status) &&
|
||||||
|
successCount > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="flex-1 text-center">
|
||||||
|
<div className="text-2xl font-bold text-green-600">
|
||||||
|
{successCount}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground uppercase">
|
||||||
|
{t("ui.common.success", "성공")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-px h-10 bg-border" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<div className="flex-1 text-center">
|
<div className="flex-1 text-center">
|
||||||
<div className="text-2xl font-bold text-destructive">
|
<div className="text-2xl font-bold text-destructive">
|
||||||
{failCount}
|
{failCount}
|
||||||
@@ -643,7 +683,60 @@ export function UserBulkUploadModal({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="font-medium truncate">{r.email}</div>
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="font-medium truncate">{r.email}</div>
|
||||||
|
{r.success && r.status === "created" && (
|
||||||
|
<span className="px-1.5 py-0.5 rounded-full bg-green-100 text-green-700 text-[10px] font-bold">
|
||||||
|
{t("ui.common.status.new", "신규")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{r.success && r.status === "updated" && (
|
||||||
|
<span className="px-1.5 py-0.5 rounded-full bg-blue-100 text-blue-700 text-[10px] font-bold">
|
||||||
|
{t("ui.common.status.updated", "수정")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{r.success && r.status === "unchanged" && (
|
||||||
|
<span className="px-1.5 py-0.5 rounded-full bg-slate-100 text-slate-600 text-[10px] font-bold">
|
||||||
|
{t("ui.common.status.unchanged", "동일")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{r.success && !r.status && (
|
||||||
|
<span className="px-1.5 py-0.5 rounded-full bg-green-100 text-green-700 text-[10px] font-bold">
|
||||||
|
{t("ui.common.success", "성공")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{r.success && r.status === "updated" && (
|
||||||
|
<div className="mt-1 text-[10px] text-muted-foreground flex flex-wrap gap-1 items-center">
|
||||||
|
<span className="font-medium">
|
||||||
|
{t(
|
||||||
|
"ui.admin.users.bulk.modified_fields",
|
||||||
|
"수정 항목:",
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{r.modifiedFields &&
|
||||||
|
r.modifiedFields.length > 0 &&
|
||||||
|
r.modifiedFields.map((field) => (
|
||||||
|
<span
|
||||||
|
key={field}
|
||||||
|
className="px-1 py-0.5 rounded bg-blue-50 text-blue-600 border border-blue-100"
|
||||||
|
>
|
||||||
|
{t(
|
||||||
|
`ui.admin.users.field.${field.toLowerCase()}`,
|
||||||
|
field,
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{r.success && r.status === "unchanged" && (
|
||||||
|
<div className="mt-1 text-[10px] text-muted-foreground italic">
|
||||||
|
{t(
|
||||||
|
"ui.admin.users.bulk.no_changes",
|
||||||
|
"기존 데이터와 동일 (변경 사항 없음)",
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{!r.success && (
|
{!r.success && (
|
||||||
<div className="text-xs text-destructive">
|
<div className="text-xs text-destructive">
|
||||||
{r.message}
|
{r.message}
|
||||||
|
|||||||
@@ -28,7 +28,10 @@ export function parseUserCSV(text: string): BulkUserItem[] {
|
|||||||
const value = values[index];
|
const value = values[index];
|
||||||
if (value === undefined || value === "") continue;
|
if (value === undefined || value === "") continue;
|
||||||
|
|
||||||
if (header === "email") {
|
if (header === "uuid") {
|
||||||
|
item.id = value;
|
||||||
|
item.uuid = value;
|
||||||
|
} else if (header === "email") {
|
||||||
item.email = value;
|
item.email = value;
|
||||||
} else if (header === "name") {
|
} else if (header === "name") {
|
||||||
item.name = value;
|
item.name = value;
|
||||||
@@ -115,8 +118,18 @@ export function parseUserCSV(text: string): BulkUserItem[] {
|
|||||||
} else if (header === "firstname") {
|
} else if (header === "firstname") {
|
||||||
item.metadata.naverworks_first_name = value;
|
item.metadata.naverworks_first_name = value;
|
||||||
} else if (header === "id") {
|
} else if (header === "id") {
|
||||||
item.loginId = value;
|
// If it looks like a UUID, use it as item.id
|
||||||
item.metadata.naverworks_id = value;
|
if (
|
||||||
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
|
||||||
|
value,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
item.id = value;
|
||||||
|
item.uuid = value;
|
||||||
|
} else {
|
||||||
|
item.loginId = value;
|
||||||
|
item.metadata.naverworks_id = value;
|
||||||
|
}
|
||||||
} else if (header === "personalemail") {
|
} else if (header === "personalemail") {
|
||||||
item.metadata.personal_email = value;
|
item.metadata.personal_email = value;
|
||||||
} else if (header === "subemail") {
|
} else if (header === "subemail") {
|
||||||
|
|||||||
@@ -725,6 +725,8 @@ export type BulkUserAppointment = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type BulkUserItem = {
|
export type BulkUserItem = {
|
||||||
|
id?: string;
|
||||||
|
uuid?: string;
|
||||||
email: string;
|
email: string;
|
||||||
loginId?: string;
|
loginId?: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -761,6 +763,7 @@ export type BulkUserResult = {
|
|||||||
success: boolean;
|
success: boolean;
|
||||||
message?: string;
|
message?: string;
|
||||||
userId?: string;
|
userId?: string;
|
||||||
|
modifiedFields?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type BulkUserResponse = {
|
export type BulkUserResponse = {
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ test.describe("Users Bulk Upload Secondary Emails", () => {
|
|||||||
|
|
||||||
await page.getByTestId("bulk-start-btn").click();
|
await page.getByTestId("bulk-start-btn").click();
|
||||||
|
|
||||||
await expect(page.getByText(/성공|Success/i)).toBeVisible();
|
await expect(page.getByText(/성공|Success/i).first()).toBeVisible();
|
||||||
|
|
||||||
expect(bulkPayload).not.toBeNull();
|
expect(bulkPayload).not.toBeNull();
|
||||||
expect(bulkPayload.users).toHaveLength(1);
|
expect(bulkPayload.users).toHaveLength(1);
|
||||||
|
|||||||
178
adminfront/tests/users_bulk_uuid.spec.ts
Normal file
178
adminfront/tests/users_bulk_uuid.spec.ts
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
test.describe("Users Bulk Upload UUID Support", () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
window.localStorage.setItem("locale", "ko");
|
||||||
|
window.localStorage.setItem("admin_session", "fake-token");
|
||||||
|
(
|
||||||
|
window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }
|
||||||
|
)._IS_TEST_MODE = true;
|
||||||
|
|
||||||
|
const authority = "http://localhost:5000/oidc";
|
||||||
|
const client_id = "adminfront";
|
||||||
|
const key = `oidc.user:${authority}:${client_id}`;
|
||||||
|
const authData = {
|
||||||
|
access_token: "fake-token",
|
||||||
|
token_type: "Bearer",
|
||||||
|
profile: { sub: "admin-user", name: "Admin", role: "super_admin" },
|
||||||
|
expires_at: Math.floor(Date.now() / 1000) + 36000,
|
||||||
|
};
|
||||||
|
window.localStorage.setItem(key, JSON.stringify(authData));
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.route("**/api/v1/**", async (route) => {
|
||||||
|
const url = route.request().url();
|
||||||
|
const headers = { "Access-Control-Allow-Origin": "*" };
|
||||||
|
|
||||||
|
if (url.includes("/user/me")) {
|
||||||
|
return route.fulfill({
|
||||||
|
json: {
|
||||||
|
id: "admin-user",
|
||||||
|
name: "Admin",
|
||||||
|
role: "super_admin",
|
||||||
|
manageableTenants: [],
|
||||||
|
},
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (url.includes("/admin/users")) {
|
||||||
|
if (!url.includes("/bulk")) {
|
||||||
|
return route.fulfill({
|
||||||
|
json: { items: [], total: 0, limit: 50, offset: 0 },
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (url.includes("/admin/tenants")) {
|
||||||
|
return route.fulfill({
|
||||||
|
json: { items: [], total: 0, limit: 100, offset: 0 },
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return route.fulfill({ json: { items: [], total: 0 }, headers });
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.route("**/oidc/**", async (route) => {
|
||||||
|
await route.fulfill({ json: { issuer: "http://localhost:5000/oidc" } });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should include UUID in bulk upload payload when provided in CSV", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
let bulkPayload = "";
|
||||||
|
const testUuid = "550e8400-e29b-41d4-a716-446655440000";
|
||||||
|
|
||||||
|
await page.route("**/api/v1/admin/users/bulk", async (route) => {
|
||||||
|
bulkPayload = route.request().postData() ?? "";
|
||||||
|
return route.fulfill({
|
||||||
|
json: {
|
||||||
|
results: [
|
||||||
|
{ email: "uuid@test.com", success: true, userId: testUuid },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
headers: { "Access-Control-Allow-Origin": "*" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/users");
|
||||||
|
await page.getByTestId("user-data-mgmt-btn").click();
|
||||||
|
await page
|
||||||
|
.getByRole("menuitem", { name: /일괄 임포트|Bulk Import/i })
|
||||||
|
.click();
|
||||||
|
|
||||||
|
await page.locator('input[type="file"]').setInputFiles({
|
||||||
|
name: "users_uuid.csv",
|
||||||
|
mimeType: "text/csv",
|
||||||
|
buffer: Buffer.from(
|
||||||
|
`email,name,uuid\nuuid@test.com,UUID User,${testUuid}\n`,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByTestId("bulk-start-btn").click();
|
||||||
|
await expect(page.getByText("uuid@test.com")).toBeVisible();
|
||||||
|
|
||||||
|
const payload = JSON.parse(bulkPayload);
|
||||||
|
expect(payload.users[0].uuid).toBe(testUuid);
|
||||||
|
expect(payload.users[0].id).toBe(testUuid);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should support 'id' column as UUID if format matches", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
let bulkPayload = "";
|
||||||
|
const testUuid = "550e8400-e29b-41d4-a716-446655440001";
|
||||||
|
|
||||||
|
await page.route("**/api/v1/admin/users/bulk", async (route) => {
|
||||||
|
bulkPayload = route.request().postData() ?? "";
|
||||||
|
return route.fulfill({
|
||||||
|
json: {
|
||||||
|
results: [
|
||||||
|
{ email: "id-uuid@test.com", success: true, userId: testUuid },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
headers: { "Access-Control-Allow-Origin": "*" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/users");
|
||||||
|
await page.getByTestId("user-data-mgmt-btn").click();
|
||||||
|
await page
|
||||||
|
.getByRole("menuitem", { name: /일괄 임포트|Bulk Import/i })
|
||||||
|
.click();
|
||||||
|
|
||||||
|
await page.locator('input[type="file"]').setInputFiles({
|
||||||
|
name: "users_id.csv",
|
||||||
|
mimeType: "text/csv",
|
||||||
|
buffer: Buffer.from(
|
||||||
|
`email,name,id\nid-uuid@test.com,ID UUID User,${testUuid}\n`,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByTestId("bulk-start-btn").click();
|
||||||
|
|
||||||
|
const payload = JSON.parse(bulkPayload);
|
||||||
|
expect(payload.users[0].uuid).toBe(testUuid);
|
||||||
|
expect(payload.users[0].id).toBe(testUuid);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should show conflict error message when UUID already exists", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const testUuid = "550e8400-e29b-41d4-a716-446655440002";
|
||||||
|
const conflictMsg = `Conflict: UUID already exists (${testUuid})`;
|
||||||
|
|
||||||
|
await page.route("**/api/v1/admin/users/bulk", async (route) => {
|
||||||
|
return route.fulfill({
|
||||||
|
json: {
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
email: "conflict@test.com",
|
||||||
|
success: false,
|
||||||
|
message: conflictMsg,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
headers: { "Access-Control-Allow-Origin": "*" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/users");
|
||||||
|
await page.getByTestId("user-data-mgmt-btn").click();
|
||||||
|
await page
|
||||||
|
.getByRole("menuitem", { name: /일괄 임포트|Bulk Import/i })
|
||||||
|
.click();
|
||||||
|
|
||||||
|
await page.locator('input[type="file"]').setInputFiles({
|
||||||
|
name: "users_conflict.csv",
|
||||||
|
mimeType: "text/csv",
|
||||||
|
buffer: Buffer.from(
|
||||||
|
`email,name,uuid\nconflict@test.com,Conflict User,${testUuid}\n`,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByTestId("bulk-start-btn").click();
|
||||||
|
await expect(page.getByText(conflictMsg)).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -110,7 +110,7 @@ func (m *e2eMockKratosAdminService) ListIdentities(ctx context.Context) ([]servi
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *e2eMockKratosAdminService) UpdateIdentity(ctx context.Context, identityID string, traits map[string]interface{}, state string) (*service.KratosIdentity, error) {
|
func (m *e2eMockKratosAdminService) UpdateIdentity(ctx context.Context, identityID string, traits map[string]any, state string) (*service.KratosIdentity, error) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -325,7 +325,7 @@ func runHeadlessPasswordLoginE2ERequest(
|
|||||||
Client: domain.HydraClient{
|
Client: domain.HydraClient{
|
||||||
ClientID: "headless-login-client",
|
ClientID: "headless-login-client",
|
||||||
TokenEndpointAuthMethod: "none",
|
TokenEndpointAuthMethod: "none",
|
||||||
Metadata: map[string]interface{}{
|
Metadata: map[string]any{
|
||||||
"status": "active",
|
"status": "active",
|
||||||
"headless_login_enabled": true,
|
"headless_login_enabled": true,
|
||||||
"headless_token_endpoint_auth_method": "private_key_jwt",
|
"headless_token_endpoint_auth_method": "private_key_jwt",
|
||||||
|
|||||||
@@ -459,7 +459,7 @@ func main() {
|
|||||||
app.Use(cors.New(cors.Config{
|
app.Use(cors.New(cors.Config{
|
||||||
AllowOriginsFunc: func(origin string) bool {
|
AllowOriginsFunc: func(origin string) bool {
|
||||||
// 1. Check static allowed list
|
// 1. Check static allowed list
|
||||||
for _, allowed := range strings.Split(allowedOrigins, ",") {
|
for allowed := range strings.SplitSeq(allowedOrigins, ",") {
|
||||||
if origin == strings.TrimSpace(allowed) {
|
if origin == strings.TrimSpace(allowed) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -857,9 +857,9 @@ func main() {
|
|||||||
// Client Logging Route (Standardized & Flattened)
|
// Client Logging Route (Standardized & Flattened)
|
||||||
api.Post("/client-log", func(c *fiber.Ctx) error {
|
api.Post("/client-log", func(c *fiber.Ctx) error {
|
||||||
type LogReq struct {
|
type LogReq struct {
|
||||||
Level string `json:"level"`
|
Level string `json:"level"`
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
Data map[string]interface{} `json:"data,omitempty"`
|
Data map[string]any `json:"data,omitempty"`
|
||||||
}
|
}
|
||||||
var req LogReq
|
var req LogReq
|
||||||
if err := c.BodyParser(&req); err != nil {
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ func buildSuperAdminBrokerUser(email, name string) *domain.BrokerUser {
|
|||||||
Email: email,
|
Email: email,
|
||||||
Name: name,
|
Name: name,
|
||||||
PhoneNumber: "",
|
PhoneNumber: "",
|
||||||
Attributes: map[string]interface{}{
|
Attributes: map[string]any{
|
||||||
"department": "Admin",
|
"department": "Admin",
|
||||||
"affiliationType": "internal",
|
"affiliationType": "internal",
|
||||||
"grade": "",
|
"grade": "",
|
||||||
@@ -170,7 +170,7 @@ func (s *gormSuperAdminStore) CreateUser(ctx context.Context, user *domain.User)
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *gormSuperAdminStore) UpdateUserSuperAdmin(ctx context.Context, userID string, name string) (*domain.User, error) {
|
func (s *gormSuperAdminStore) UpdateUserSuperAdmin(ctx context.Context, userID string, name string) (*domain.User, error) {
|
||||||
updates := map[string]interface{}{
|
updates := map[string]any{
|
||||||
"role": domain.RoleSuperAdmin,
|
"role": domain.RoleSuperAdmin,
|
||||||
"status": domain.UserStatusActive,
|
"status": domain.UserStatusActive,
|
||||||
"updated_at": time.Now(),
|
"updated_at": time.Now(),
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ func SeedAdminIdentity(idp domain.IdentityProvider) (string, error) {
|
|||||||
Email: adminEmail,
|
Email: adminEmail,
|
||||||
Name: adminName,
|
Name: adminName,
|
||||||
PhoneNumber: "",
|
PhoneNumber: "",
|
||||||
Attributes: map[string]interface{}{
|
Attributes: map[string]any{
|
||||||
"department": "Admin",
|
"department": "Admin",
|
||||||
"affiliationType": "internal",
|
"affiliationType": "internal",
|
||||||
"grade": "",
|
"grade": "",
|
||||||
@@ -44,7 +44,7 @@ func SeedAdminIdentity(idp domain.IdentityProvider) (string, error) {
|
|||||||
var err error
|
var err error
|
||||||
var identityID string
|
var identityID string
|
||||||
|
|
||||||
for i := 0; i < maxRetries; i++ {
|
for i := range maxRetries {
|
||||||
identityID, err = idp.CreateUser(user, adminPassword)
|
identityID, err = idp.CreateUser(user, adminPassword)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
slog.Info("[Bootstrap] Admin identity created in IDP", "email", adminEmail, "idp", idp.Name(), "id", identityID)
|
slog.Info("[Bootstrap] Admin identity created in IDP", "email", adminEmail, "idp", idp.Name(), "id", identityID)
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ func SyncAdminRole(db *gorm.DB, kratosID string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update role if needed
|
// Update role if needed
|
||||||
updates := map[string]interface{}{}
|
updates := map[string]any{}
|
||||||
if user.Role != domain.RoleSuperAdmin {
|
if user.Role != domain.RoleSuperAdmin {
|
||||||
updates["role"] = domain.RoleSuperAdmin
|
updates["role"] = domain.RoleSuperAdmin
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,15 +98,15 @@ var hanmacInitialRomanization = []string{
|
|||||||
|
|
||||||
func SplitEmailDomain(email string) (string, string, error) {
|
func SplitEmailDomain(email string) (string, string, error) {
|
||||||
normalized := strings.ToLower(strings.TrimSpace(email))
|
normalized := strings.ToLower(strings.TrimSpace(email))
|
||||||
at := strings.Index(normalized, "@")
|
before, after, ok := strings.Cut(normalized, "@")
|
||||||
if at < 0 {
|
if !ok {
|
||||||
return "", "", errors.New("email must contain @")
|
return "", "", errors.New("email must contain @")
|
||||||
}
|
}
|
||||||
if strings.Count(normalized, "@") != 1 {
|
if strings.Count(normalized, "@") != 1 {
|
||||||
return "", "", errors.New("email must contain one @")
|
return "", "", errors.New("email must contain one @")
|
||||||
}
|
}
|
||||||
localPart := strings.TrimSpace(normalized[:at])
|
localPart := strings.TrimSpace(before)
|
||||||
domainPart := strings.TrimSpace(normalized[at+1:])
|
domainPart := strings.TrimSpace(after)
|
||||||
if domainPart == "" || !strings.Contains(domainPart, ".") {
|
if domainPart == "" || !strings.Contains(domainPart, ".") {
|
||||||
return "", "", errors.New("email domain is invalid")
|
return "", "", errors.New("email domain is invalid")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,21 +19,21 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type HydraClient struct {
|
type HydraClient struct {
|
||||||
ClientID string `json:"client_id"`
|
ClientID string `json:"client_id"`
|
||||||
ClientName string `json:"client_name,omitempty"`
|
ClientName string `json:"client_name,omitempty"`
|
||||||
ClientSecret string `json:"client_secret,omitempty"` // Added
|
ClientSecret string `json:"client_secret,omitempty"` // Added
|
||||||
ClientURI string `json:"client_uri,omitempty"`
|
ClientURI string `json:"client_uri,omitempty"`
|
||||||
RedirectURIs []string `json:"redirect_uris,omitempty"`
|
RedirectURIs []string `json:"redirect_uris,omitempty"`
|
||||||
GrantTypes []string `json:"grant_types,omitempty"`
|
GrantTypes []string `json:"grant_types,omitempty"`
|
||||||
ResponseTypes []string `json:"response_types,omitempty"`
|
ResponseTypes []string `json:"response_types,omitempty"`
|
||||||
Scope string `json:"scope,omitempty"`
|
Scope string `json:"scope,omitempty"`
|
||||||
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"`
|
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"`
|
||||||
SkipConsent *bool `json:"skip_consent,omitempty"`
|
SkipConsent *bool `json:"skip_consent,omitempty"`
|
||||||
JWKSUri string `json:"jwks_uri,omitempty"`
|
JWKSUri string `json:"jwks_uri,omitempty"`
|
||||||
JWKS interface{} `json:"jwks,omitempty"`
|
JWKS any `json:"jwks,omitempty"`
|
||||||
BackChannelLogoutURI string `json:"backchannel_logout_uri,omitempty"`
|
BackChannelLogoutURI string `json:"backchannel_logout_uri,omitempty"`
|
||||||
BackChannelLogoutSessionRequired *bool `json:"backchannel_logout_session_required,omitempty"`
|
BackChannelLogoutSessionRequired *bool `json:"backchannel_logout_session_required,omitempty"`
|
||||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
Metadata map[string]any `json:"metadata,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *HydraClient) SupportsHeadlessLogin() bool {
|
func (c *HydraClient) SupportsHeadlessLogin() bool {
|
||||||
@@ -65,7 +65,7 @@ func (c *HydraClient) HeadlessJWKSURI() string {
|
|||||||
return strings.TrimSpace(c.JWKSUri)
|
return strings.TrimSpace(c.JWKSUri)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *HydraClient) HeadlessJWKS() interface{} {
|
func (c *HydraClient) HeadlessJWKS() any {
|
||||||
if c.Metadata != nil {
|
if c.Metadata != nil {
|
||||||
if value, ok := c.Metadata[MetadataHeadlessJWKS]; ok && value != nil {
|
if value, ok := c.Metadata[MetadataHeadlessJWKS]; ok && value != nil {
|
||||||
return value
|
return value
|
||||||
@@ -140,6 +140,6 @@ type HydraConsentSession struct {
|
|||||||
AuthenticatedAt *time.Time `json:"authenticated_at,omitempty"`
|
AuthenticatedAt *time.Time `json:"authenticated_at,omitempty"`
|
||||||
RequestedAt *time.Time `json:"requested_at,omitempty"`
|
RequestedAt *time.Time `json:"requested_at,omitempty"`
|
||||||
HandledAt *time.Time `json:"handled_at,omitempty"`
|
HandledAt *time.Time `json:"handled_at,omitempty"`
|
||||||
Client HydraClient `json:"client,omitempty"`
|
Client HydraClient `json:"client"`
|
||||||
ConsentRequest *HydraConsentRequest `json:"consent_request,omitempty"`
|
ConsentRequest *HydraConsentRequest `json:"consent_request,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ type BrokerUser struct {
|
|||||||
PhoneNumber string `json:"phone_number"`
|
PhoneNumber string `json:"phone_number"`
|
||||||
// Attributes stores custom user attributes.
|
// Attributes stores custom user attributes.
|
||||||
// The "required_keys" tag specifies which keys MUST be present in the IDP's schema support.
|
// The "required_keys" tag specifies which keys MUST be present in the IDP's schema support.
|
||||||
Attributes map[string]interface{} `json:"attributes" required_keys:"grade,department"`
|
Attributes map[string]any `json:"attributes" required_keys:"grade,department"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// IDPMetadata represents the schema capabilities of an Identity Provider.
|
// IDPMetadata represents the schema capabilities of an Identity Provider.
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package domain
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -208,10 +209,8 @@ func ValidateLoginID(loginID string, emails []string, phone string) error {
|
|||||||
|
|
||||||
reserved := []string{"admin", "system", "root", "master", "superuser", "guest", "operator"}
|
reserved := []string{"admin", "system", "root", "master", "superuser", "guest", "operator"}
|
||||||
lowerID := strings.ToLower(loginID)
|
lowerID := strings.ToLower(loginID)
|
||||||
for _, r := range reserved {
|
if slices.Contains(reserved, lowerID) {
|
||||||
if lowerID == r {
|
return fmt.Errorf("reserved ID cannot be used")
|
||||||
return fmt.Errorf("reserved ID cannot be used")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ func TestApiKeyHandler_CreateApiKey(t *testing.T) {
|
|||||||
|
|
||||||
app.Post("/api-keys", h.CreateApiKey)
|
app.Post("/api-keys", h.CreateApiKey)
|
||||||
|
|
||||||
input := map[string]interface{}{
|
input := map[string]any{
|
||||||
"name": "M2M Test",
|
"name": "M2M Test",
|
||||||
"scopes": []string{"read", "write"},
|
"scopes": []string{"read", "write"},
|
||||||
}
|
}
|
||||||
@@ -47,7 +47,7 @@ func TestApiKeyHandler_Validation(t *testing.T) {
|
|||||||
app.Post("/api-keys", h.CreateApiKey)
|
app.Post("/api-keys", h.CreateApiKey)
|
||||||
|
|
||||||
// Missing name
|
// Missing name
|
||||||
input := map[string]interface{}{
|
input := map[string]any{
|
||||||
"scopes": []string{"read"},
|
"scopes": []string{"read"},
|
||||||
}
|
}
|
||||||
body, _ := json.Marshal(input)
|
body, _ := json.Marshal(input)
|
||||||
@@ -65,7 +65,7 @@ func TestApiKeyHandler_UpdateApiKeyScopesRequiresDatabase(t *testing.T) {
|
|||||||
|
|
||||||
app.Patch("/api-keys/:id", h.UpdateApiKey)
|
app.Patch("/api-keys/:id", h.UpdateApiKey)
|
||||||
|
|
||||||
body, _ := json.Marshal(map[string]interface{}{
|
body, _ := json.Marshal(map[string]any{
|
||||||
"scopes": []string{"org-context:read"},
|
"scopes": []string{"org-context:read"},
|
||||||
})
|
})
|
||||||
req := httptest.NewRequest("PATCH", "/api-keys/api-key-id", bytes.NewReader(body))
|
req := httptest.NewRequest("PATCH", "/api-keys/api-key-id", bytes.NewReader(body))
|
||||||
|
|||||||
@@ -16,11 +16,13 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"maps"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
"slices"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -601,7 +603,7 @@ func (h *AuthHandler) GetActiveTenants(c *fiber.Ctx) error {
|
|||||||
email := c.Query("email")
|
email := c.Query("email")
|
||||||
if email == "" {
|
if email == "" {
|
||||||
// No email provided, return empty list (Security policy)
|
// No email provided, return empty list (Security policy)
|
||||||
return c.JSON([]interface{}{})
|
return c.JSON([]any{})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Verify Verification Status in Redis
|
// 1. Verify Verification Status in Redis
|
||||||
@@ -615,7 +617,7 @@ func (h *AuthHandler) GetActiveTenants(c *fiber.Ctx) error {
|
|||||||
// 2. Extract domain from verified email
|
// 2. Extract domain from verified email
|
||||||
parts := strings.Split(email, "@")
|
parts := strings.Split(email, "@")
|
||||||
if len(parts) != 2 {
|
if len(parts) != 2 {
|
||||||
return c.JSON([]interface{}{})
|
return c.JSON([]any{})
|
||||||
}
|
}
|
||||||
domainName := parts[1]
|
domainName := parts[1]
|
||||||
|
|
||||||
@@ -623,7 +625,7 @@ func (h *AuthHandler) GetActiveTenants(c *fiber.Ctx) error {
|
|||||||
isInternal, _ := h.isAffiliateTenant(c.Context(), domainName)
|
isInternal, _ := h.isAffiliateTenant(c.Context(), domainName)
|
||||||
if !isInternal {
|
if !isInternal {
|
||||||
// If not an affiliate email, do not show any tenants
|
// If not an affiliate email, do not show any tenants
|
||||||
return c.JSON([]interface{}{})
|
return c.JSON([]any{})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. List and Filter Tenants
|
// 3. List and Filter Tenants
|
||||||
@@ -785,7 +787,7 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error {
|
|||||||
slog.Info("[Signup] Phone normalization", "raw", req.Phone, "normalized", normalizedPhone)
|
slog.Info("[Signup] Phone normalization", "raw", req.Phone, "normalized", normalizedPhone)
|
||||||
|
|
||||||
// IDP에 전달할 BrokerUser 스키마 구성
|
// IDP에 전달할 BrokerUser 스키마 구성
|
||||||
attributes := map[string]interface{}{
|
attributes := map[string]any{
|
||||||
"department": req.Department,
|
"department": req.Department,
|
||||||
"affiliationType": req.AffiliationType,
|
"affiliationType": req.AffiliationType,
|
||||||
"grade": "",
|
"grade": "",
|
||||||
@@ -854,9 +856,7 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
// Merge metadata
|
// Merge metadata
|
||||||
localUser.Metadata = make(domain.JSONMap)
|
localUser.Metadata = make(domain.JSONMap)
|
||||||
for k, v := range req.Metadata {
|
maps.Copy(localUser.Metadata, req.Metadata)
|
||||||
localUser.Metadata[k] = v
|
|
||||||
}
|
|
||||||
|
|
||||||
if h.UserRepo != nil {
|
if h.UserRepo != nil {
|
||||||
go func(u *domain.User, ids []domain.UserLoginID) {
|
go func(u *domain.User, ids []domain.UserLoginID) {
|
||||||
@@ -915,7 +915,7 @@ func (h *AuthHandler) getBearerToken(c *fiber.Ctx) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func firstForwardedValue(raw string) string {
|
func firstForwardedValue(raw string) string {
|
||||||
for _, part := range strings.Split(raw, ",") {
|
for part := range strings.SplitSeq(raw, ",") {
|
||||||
value := strings.TrimSpace(part)
|
value := strings.TrimSpace(part)
|
||||||
if value != "" {
|
if value != "" {
|
||||||
return value
|
return value
|
||||||
@@ -925,8 +925,8 @@ func firstForwardedValue(raw string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func forwardedDirective(raw, key string) string {
|
func forwardedDirective(raw, key string) string {
|
||||||
for _, group := range strings.Split(raw, ",") {
|
for group := range strings.SplitSeq(raw, ",") {
|
||||||
for _, directive := range strings.Split(group, ";") {
|
for directive := range strings.SplitSeq(group, ";") {
|
||||||
pair := strings.SplitN(strings.TrimSpace(directive), "=", 2)
|
pair := strings.SplitN(strings.TrimSpace(directive), "=", 2)
|
||||||
if len(pair) != 2 {
|
if len(pair) != 2 {
|
||||||
continue
|
continue
|
||||||
@@ -1075,9 +1075,9 @@ func (h *AuthHandler) GetTenantInfo(c *fiber.Ctx) error {
|
|||||||
if loginIdField, ok := tenant.Config["loginIdField"].(string); ok && loginIdField != "" {
|
if loginIdField, ok := tenant.Config["loginIdField"].(string); ok && loginIdField != "" {
|
||||||
res["loginIdField"] = loginIdField
|
res["loginIdField"] = loginIdField
|
||||||
// Find label in userSchema
|
// Find label in userSchema
|
||||||
if schema, ok := tenant.Config["userSchema"].([]interface{}); ok {
|
if schema, ok := tenant.Config["userSchema"].([]any); ok {
|
||||||
for _, field := range schema {
|
for _, field := range schema {
|
||||||
if f, ok := field.(map[string]interface{}); ok {
|
if f, ok := field.(map[string]any); ok {
|
||||||
if f["key"] == loginIdField {
|
if f["key"] == loginIdField {
|
||||||
res["loginIdLabel"] = f["label"]
|
res["loginIdLabel"] = f["label"]
|
||||||
break
|
break
|
||||||
@@ -1215,9 +1215,7 @@ func buildOidcClaimsFromTraits(traits map[string]any, scopes []string, tenantID
|
|||||||
if includeTenantDetails {
|
if includeTenantDetails {
|
||||||
// tenant 스코프가 있을 때만 대표소속 namespace metadata를 top-level claim으로 펼칩니다.
|
// tenant 스코프가 있을 때만 대표소속 namespace metadata를 top-level claim으로 펼칩니다.
|
||||||
if namespaced, ok := traits[tenantID].(map[string]any); ok {
|
if namespaced, ok := traits[tenantID].(map[string]any); ok {
|
||||||
for k, v := range namespaced {
|
maps.Copy(claims, namespaced)
|
||||||
claims[k] = v
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1570,7 +1568,7 @@ func tenantClaimAncestorSummaries(ancestors []*domain.Tenant) []map[string]any {
|
|||||||
return items
|
return items
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyConfiguredIDTokenClaims(baseClaims map[string]any, metadata map[string]interface{}) map[string]any {
|
func applyConfiguredIDTokenClaims(baseClaims map[string]any, metadata map[string]any) map[string]any {
|
||||||
if baseClaims == nil {
|
if baseClaims == nil {
|
||||||
baseClaims = map[string]any{}
|
baseClaims = map[string]any{}
|
||||||
}
|
}
|
||||||
@@ -1670,7 +1668,7 @@ func (h *AuthHandler) withRPProfileClaims(ctx context.Context, claims map[string
|
|||||||
claims["rp_profiles"] = append(existing, profile)
|
claims["rp_profiles"] = append(existing, profile)
|
||||||
return claims
|
return claims
|
||||||
}
|
}
|
||||||
if existing, ok := claims["rp_profiles"].([]interface{}); ok {
|
if existing, ok := claims["rp_profiles"].([]any); ok {
|
||||||
claims["rp_profiles"] = append(existing, profile)
|
claims["rp_profiles"] = append(existing, profile)
|
||||||
return claims
|
return claims
|
||||||
}
|
}
|
||||||
@@ -1678,7 +1676,7 @@ func (h *AuthHandler) withRPProfileClaims(ctx context.Context, claims map[string
|
|||||||
return claims
|
return claims
|
||||||
}
|
}
|
||||||
|
|
||||||
func extractClaimEnabledCustomUserSchemaKeys(metadata map[string]interface{}) []string {
|
func extractClaimEnabledCustomUserSchemaKeys(metadata map[string]any) []string {
|
||||||
if metadata == nil {
|
if metadata == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -1687,12 +1685,12 @@ func extractClaimEnabledCustomUserSchemaKeys(metadata map[string]interface{}) []
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var items []interface{}
|
var items []any
|
||||||
switch schema := rawSchema.(type) {
|
switch schema := rawSchema.(type) {
|
||||||
case []interface{}:
|
case []any:
|
||||||
items = schema
|
items = schema
|
||||||
case []map[string]interface{}:
|
case []map[string]any:
|
||||||
items = make([]interface{}, 0, len(schema))
|
items = make([]any, 0, len(schema))
|
||||||
for _, item := range schema {
|
for _, item := range schema {
|
||||||
items = append(items, item)
|
items = append(items, item)
|
||||||
}
|
}
|
||||||
@@ -1703,7 +1701,7 @@ func extractClaimEnabledCustomUserSchemaKeys(metadata map[string]interface{}) []
|
|||||||
keys := make([]string, 0, len(items))
|
keys := make([]string, 0, len(items))
|
||||||
seen := make(map[string]struct{})
|
seen := make(map[string]struct{})
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
field, ok := item.(map[string]interface{})
|
field, ok := item.(map[string]any)
|
||||||
if !ok {
|
if !ok {
|
||||||
if typed, typedOK := item.(map[string]any); typedOK {
|
if typed, typedOK := item.(map[string]any); typedOK {
|
||||||
field = typed
|
field = typed
|
||||||
@@ -4275,11 +4273,11 @@ func (h *AuthHandler) ScanQRLogin(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type kratosCourierRequest struct {
|
type kratosCourierRequest struct {
|
||||||
Recipient string `json:"recipient"`
|
Recipient string `json:"recipient"`
|
||||||
TemplateType string `json:"template_type"`
|
TemplateType string `json:"template_type"`
|
||||||
TemplateData map[string]interface{} `json:"template_data"`
|
TemplateData map[string]any `json:"template_data"`
|
||||||
Subject string `json:"subject"`
|
Subject string `json:"subject"`
|
||||||
Body string `json:"body"`
|
Body string `json:"body"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleKratosCourierRelay - Kratos courier HTTP 요청을 받아 메일/SMS 발송으로 변환합니다.
|
// HandleKratosCourierRelay - Kratos courier HTTP 요청을 받아 메일/SMS 발송으로 변환합니다.
|
||||||
@@ -4604,7 +4602,7 @@ func (h *AuthHandler) isSmsCodeOnly(loginID string) bool {
|
|||||||
|
|
||||||
func (h *AuthHandler) generateShortCode(code string) string {
|
func (h *AuthHandler) generateShortCode(code string) string {
|
||||||
const letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
const letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||||
for i := 0; i < 10; i++ {
|
for range 10 {
|
||||||
b := make([]byte, 2)
|
b := make([]byte, 2)
|
||||||
if _, err := crand.Read(b); err != nil {
|
if _, err := crand.Read(b); err != nil {
|
||||||
break
|
break
|
||||||
@@ -4646,7 +4644,7 @@ func firstNonEmpty(values ...string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func extractFirstString(data map[string]interface{}, keys ...string) string {
|
func extractFirstString(data map[string]any, keys ...string) string {
|
||||||
if data == nil {
|
if data == nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
@@ -5229,10 +5227,7 @@ func (h *AuthHandler) GetAuthTimeline(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
candidates := buildLoginCandidates(profile)
|
candidates := buildLoginCandidates(profile)
|
||||||
fetchLimit := limit * 10
|
fetchLimit := max(limit*10, limit)
|
||||||
if fetchLimit < limit {
|
|
||||||
fetchLimit = limit
|
|
||||||
}
|
|
||||||
if fetchLimit > 500 {
|
if fetchLimit > 500 {
|
||||||
fetchLimit = 500
|
fetchLimit = 500
|
||||||
}
|
}
|
||||||
@@ -5805,9 +5800,9 @@ func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
for _, log := range auditLogs {
|
for _, log := range auditLogs {
|
||||||
var details struct {
|
var details struct {
|
||||||
ClientID string `json:"client_id"`
|
ClientID string `json:"client_id"`
|
||||||
ClientName string `json:"client_name"`
|
ClientName string `json:"client_name"`
|
||||||
Scopes interface{} `json:"scopes"`
|
Scopes any `json:"scopes"`
|
||||||
}
|
}
|
||||||
// 로그 Details 파싱
|
// 로그 Details 파싱
|
||||||
if err := json.Unmarshal([]byte(log.Details), &details); err != nil {
|
if err := json.Unmarshal([]byte(log.Details), &details); err != nil {
|
||||||
@@ -5824,7 +5819,7 @@ func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
// 스코프 추출 (consent.granted인 경우)
|
// 스코프 추출 (consent.granted인 경우)
|
||||||
scopes := []string{}
|
scopes := []string{}
|
||||||
if sList, ok := details.Scopes.([]interface{}); ok {
|
if sList, ok := details.Scopes.([]any); ok {
|
||||||
for _, s := range sList {
|
for _, s := range sList {
|
||||||
if str, ok := s.(string); ok {
|
if str, ok := s.(string); ok {
|
||||||
scopes = append(scopes, str)
|
scopes = append(scopes, str)
|
||||||
@@ -5927,7 +5922,7 @@ func (h *AuthHandler) RevokeLinkedRp(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if h.AuditRepo != nil {
|
if h.AuditRepo != nil {
|
||||||
detailsMap := map[string]interface{}{
|
detailsMap := map[string]any{
|
||||||
"client_id": clientID,
|
"client_id": clientID,
|
||||||
}
|
}
|
||||||
detailsBytes, _ := json.Marshal(detailsMap)
|
detailsBytes, _ := json.Marshal(detailsMap)
|
||||||
@@ -6085,7 +6080,7 @@ func (h *AuthHandler) GetConsentRequest(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if h.AuditRepo != nil {
|
if h.AuditRepo != nil {
|
||||||
detailsMap := map[string]interface{}{
|
detailsMap := map[string]any{
|
||||||
"client_id": consentRequest.Client.ClientID,
|
"client_id": consentRequest.Client.ClientID,
|
||||||
"scopes": consentRequest.RequestedScope,
|
"scopes": consentRequest.RequestedScope,
|
||||||
"client_name": consentRequest.Client.ClientName,
|
"client_name": consentRequest.Client.ClientName,
|
||||||
@@ -6135,12 +6130,12 @@ func (h *AuthHandler) GetConsentRequest(c *fiber.Ctx) error {
|
|||||||
// structured_scopes 파싱 및 scope_details 생성
|
// structured_scopes 파싱 및 scope_details 생성
|
||||||
if metadata := consentRequest.Client.Metadata; metadata != nil {
|
if metadata := consentRequest.Client.Metadata; metadata != nil {
|
||||||
if rawScopes, ok := metadata["structured_scopes"]; ok {
|
if rawScopes, ok := metadata["structured_scopes"]; ok {
|
||||||
scopeDetails := make(map[string]map[string]interface{})
|
scopeDetails := make(map[string]map[string]any)
|
||||||
|
|
||||||
// JSON 언마샬링 등을 통해 map[string]interface{} 또는 []interface{}로 들어옴
|
// JSON 언마샬링 등을 통해 map[string]interface{} 또는 []interface{}로 들어옴
|
||||||
// 안전하게 처리
|
// 안전하게 처리
|
||||||
rawBytes, _ := json.Marshal(rawScopes)
|
rawBytes, _ := json.Marshal(rawScopes)
|
||||||
var scopesList []map[string]interface{}
|
var scopesList []map[string]any
|
||||||
if err := json.Unmarshal(rawBytes, &scopesList); err == nil {
|
if err := json.Unmarshal(rawBytes, &scopesList); err == nil {
|
||||||
for _, item := range scopesList {
|
for _, item := range scopesList {
|
||||||
name, _ := item["name"].(string)
|
name, _ := item["name"].(string)
|
||||||
@@ -6150,7 +6145,7 @@ func (h *AuthHandler) GetConsentRequest(c *fiber.Ctx) error {
|
|||||||
desc, _ := item["description"].(string)
|
desc, _ := item["description"].(string)
|
||||||
mandatory, _ := item["mandatory"].(bool)
|
mandatory, _ := item["mandatory"].(bool)
|
||||||
|
|
||||||
scopeDetails[name] = map[string]interface{}{
|
scopeDetails[name] = map[string]any{
|
||||||
"description": desc,
|
"description": desc,
|
||||||
"mandatory": mandatory,
|
"mandatory": mandatory,
|
||||||
}
|
}
|
||||||
@@ -6280,7 +6275,7 @@ func (h *AuthHandler) AcceptConsentRequest(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if h.AuditRepo != nil {
|
if h.AuditRepo != nil {
|
||||||
detailsMap := map[string]interface{}{
|
detailsMap := map[string]any{
|
||||||
"client_id": consentRequest.Client.ClientID,
|
"client_id": consentRequest.Client.ClientID,
|
||||||
"scopes": consentRequest.RequestedScope,
|
"scopes": consentRequest.RequestedScope,
|
||||||
"client_name": consentRequest.Client.ClientName,
|
"client_name": consentRequest.Client.ClientName,
|
||||||
@@ -6618,7 +6613,7 @@ func appendLoginIDsFromValues(subjects []string, email string, phone string) []s
|
|||||||
return subjects
|
return subjects
|
||||||
}
|
}
|
||||||
|
|
||||||
func appendLoginIDsFromTraits(subjects []string, traits map[string]interface{}) []string {
|
func appendLoginIDsFromTraits(subjects []string, traits map[string]any) []string {
|
||||||
if traits == nil {
|
if traits == nil {
|
||||||
return subjects
|
return subjects
|
||||||
}
|
}
|
||||||
@@ -7235,7 +7230,7 @@ func (h *AuthHandler) resolveKratosLoginID(token string) (string, error) {
|
|||||||
return loginID, nil
|
return loginID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func pickLoginIDFromTraits(traits map[string]interface{}) string {
|
func pickLoginIDFromTraits(traits map[string]any) string {
|
||||||
if traits == nil {
|
if traits == nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
@@ -7409,12 +7404,12 @@ func extractLoginIDFromClaims(claims map[string]any) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AuthHandler) getKratosIdentity(sessionToken string) (string, map[string]interface{}, string, error) {
|
func (h *AuthHandler) getKratosIdentity(sessionToken string) (string, map[string]any, string, error) {
|
||||||
identityID, traits, _, usedID, err := h.getKratosIdentityWithSession(sessionToken)
|
identityID, traits, _, usedID, err := h.getKratosIdentityWithSession(sessionToken)
|
||||||
return identityID, traits, usedID, err
|
return identityID, traits, usedID, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AuthHandler) getKratosIdentityWithSession(sessionToken string) (string, map[string]interface{}, string, string, error) {
|
func (h *AuthHandler) getKratosIdentityWithSession(sessionToken string) (string, map[string]any, string, string, error) {
|
||||||
kratosURL := strings.TrimRight(os.Getenv("KRATOS_PUBLIC_URL"), "/")
|
kratosURL := strings.TrimRight(os.Getenv("KRATOS_PUBLIC_URL"), "/")
|
||||||
if kratosURL == "" {
|
if kratosURL == "" {
|
||||||
kratosURL = "http://kratos:4433"
|
kratosURL = "http://kratos:4433"
|
||||||
@@ -7442,8 +7437,8 @@ func (h *AuthHandler) getKratosIdentityWithSession(sessionToken string) (string,
|
|||||||
Identifier string `json:"identifier"`
|
Identifier string `json:"identifier"`
|
||||||
} `json:"authentication_methods"`
|
} `json:"authentication_methods"`
|
||||||
Identity struct {
|
Identity struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Traits map[string]interface{} `json:"traits"`
|
Traits map[string]any `json:"traits"`
|
||||||
} `json:"identity"`
|
} `json:"identity"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||||
@@ -7501,7 +7496,7 @@ func (h *AuthHandler) issueKratosSession(ctx context.Context, identityID string)
|
|||||||
kratosAdminURL = "http://kratos:4434"
|
kratosAdminURL = "http://kratos:4434"
|
||||||
}
|
}
|
||||||
|
|
||||||
payload := map[string]interface{}{
|
payload := map[string]any{
|
||||||
"identity_id": identityID,
|
"identity_id": identityID,
|
||||||
}
|
}
|
||||||
body, _ := json.Marshal(payload)
|
body, _ := json.Marshal(payload)
|
||||||
@@ -7534,12 +7529,12 @@ func (h *AuthHandler) issueKratosSession(ctx context.Context, identityID string)
|
|||||||
return parsed.SessionToken, nil
|
return parsed.SessionToken, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AuthHandler) getKratosIdentityWithCookie(cookie string) (string, map[string]interface{}, string, error) {
|
func (h *AuthHandler) getKratosIdentityWithCookie(cookie string) (string, map[string]any, string, error) {
|
||||||
identityID, traits, _, usedID, err := h.getKratosIdentityWithCookieAndSession(cookie)
|
identityID, traits, _, usedID, err := h.getKratosIdentityWithCookieAndSession(cookie)
|
||||||
return identityID, traits, usedID, err
|
return identityID, traits, usedID, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AuthHandler) getKratosIdentityWithCookieAndSession(cookie string) (string, map[string]interface{}, string, string, error) {
|
func (h *AuthHandler) getKratosIdentityWithCookieAndSession(cookie string) (string, map[string]any, string, string, error) {
|
||||||
kratosURL := strings.TrimRight(os.Getenv("KRATOS_PUBLIC_URL"), "/")
|
kratosURL := strings.TrimRight(os.Getenv("KRATOS_PUBLIC_URL"), "/")
|
||||||
if kratosURL == "" {
|
if kratosURL == "" {
|
||||||
kratosURL = "http://kratos:4433"
|
kratosURL = "http://kratos:4433"
|
||||||
@@ -7567,8 +7562,8 @@ func (h *AuthHandler) getKratosIdentityWithCookieAndSession(cookie string) (stri
|
|||||||
Identifier string `json:"identifier"`
|
Identifier string `json:"identifier"`
|
||||||
} `json:"authentication_methods"`
|
} `json:"authentication_methods"`
|
||||||
Identity struct {
|
Identity struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Traits map[string]interface{} `json:"traits"`
|
Traits map[string]any `json:"traits"`
|
||||||
} `json:"identity"`
|
} `json:"identity"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||||
@@ -7616,13 +7611,13 @@ func (h *AuthHandler) getKratosSessionIDWithCookie(cookie string) (string, error
|
|||||||
return result.ID, nil
|
return result.ID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AuthHandler) updateKratosIdentity(identityID string, traits map[string]interface{}) error {
|
func (h *AuthHandler) updateKratosIdentity(identityID string, traits map[string]any) error {
|
||||||
kratosAdminURL := strings.TrimRight(os.Getenv("KRATOS_ADMIN_URL"), "/")
|
kratosAdminURL := strings.TrimRight(os.Getenv("KRATOS_ADMIN_URL"), "/")
|
||||||
if kratosAdminURL == "" {
|
if kratosAdminURL == "" {
|
||||||
kratosAdminURL = "http://kratos:4434"
|
kratosAdminURL = "http://kratos:4434"
|
||||||
}
|
}
|
||||||
|
|
||||||
payload := map[string]interface{}{
|
payload := map[string]any{
|
||||||
"schema_id": "default",
|
"schema_id": "default",
|
||||||
"traits": traits,
|
"traits": traits,
|
||||||
}
|
}
|
||||||
@@ -7681,7 +7676,7 @@ func (h *AuthHandler) getHydraProfile(ctx context.Context, token string) (*domai
|
|||||||
return h.mapKratosIdentityToProfile(identity.ID, identity.Traits), nil
|
return h.mapKratosIdentityToProfile(identity.ID, identity.Traits), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AuthHandler) mapKratosIdentityToProfile(identityID string, traits map[string]interface{}) *domain.UserProfileResponse {
|
func (h *AuthHandler) mapKratosIdentityToProfile(identityID string, traits map[string]any) *domain.UserProfileResponse {
|
||||||
email, _ := traits["email"].(string)
|
email, _ := traits["email"].(string)
|
||||||
name, _ := traits["name"].(string)
|
name, _ := traits["name"].(string)
|
||||||
phone, _ := traits["phone_number"].(string)
|
phone, _ := traits["phone_number"].(string)
|
||||||
@@ -7723,7 +7718,7 @@ func (h *AuthHandler) mapKratosIdentityToProfile(identityID string, traits map[s
|
|||||||
return profile
|
return profile
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AuthHandler) mapKratosTraitsToLocalUser(identityID string, traits map[string]interface{}, existing *domain.User) *domain.User {
|
func (h *AuthHandler) mapKratosTraitsToLocalUser(identityID string, traits map[string]any, existing *domain.User) *domain.User {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
localUser := &domain.User{
|
localUser := &domain.User{
|
||||||
ID: identityID,
|
ID: identityID,
|
||||||
@@ -7810,7 +7805,7 @@ func (h *AuthHandler) mapKratosTraitsToLocalUser(identityID string, traits map[s
|
|||||||
return localUser
|
return localUser
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AuthHandler) syncUpdatedKratosUserReadModel(ctx context.Context, identityID string, traits map[string]interface{}) error {
|
func (h *AuthHandler) syncUpdatedKratosUserReadModel(ctx context.Context, identityID string, traits map[string]any) error {
|
||||||
if h == nil || h.UserRepo == nil {
|
if h == nil || h.UserRepo == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -7880,7 +7875,7 @@ func (h *AuthHandler) UpdateMe(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
var (
|
var (
|
||||||
identityID string
|
identityID string
|
||||||
traits map[string]interface{}
|
traits map[string]any
|
||||||
err error
|
err error
|
||||||
)
|
)
|
||||||
if token != "" {
|
if token != "" {
|
||||||
@@ -7929,10 +7924,8 @@ func (h *AuthHandler) UpdateMe(c *fiber.Ctx) error {
|
|||||||
if _, isCore := map[string]bool{"email": true, "phone_number": true, "name": true, "department": true, "grade": true, "companyCode": true, "affiliationType": true, "id": true, "role": true, "tenant_id": true}[k]; !isCore {
|
if _, isCore := map[string]bool{"email": true, "phone_number": true, "name": true, "department": true, "grade": true, "companyCode": true, "affiliationType": true, "id": true, "role": true, "tenant_id": true}[k]; !isCore {
|
||||||
// [Fix] Support merging namespaced metadata maps
|
// [Fix] Support merging namespaced metadata maps
|
||||||
if incomingMap, ok := v.(map[string]any); ok {
|
if incomingMap, ok := v.(map[string]any); ok {
|
||||||
if existingMap, ok := traits[k].(map[string]interface{}); ok {
|
if existingMap, ok := traits[k].(map[string]any); ok {
|
||||||
for subK, subV := range incomingMap {
|
maps.Copy(existingMap, incomingMap)
|
||||||
existingMap[subK] = subV
|
|
||||||
}
|
|
||||||
traits[k] = existingMap
|
traits[k] = existingMap
|
||||||
} else {
|
} else {
|
||||||
traits[k] = incomingMap
|
traits[k] = incomingMap
|
||||||
@@ -8129,7 +8122,7 @@ func (h *AuthHandler) VerifyUpdateCode(c *fiber.Ctx) error {
|
|||||||
return c.JSON(fiber.Map{"success": true})
|
return c.JSON(fiber.Map{"success": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
func hydraClientStatus(metadata map[string]interface{}) string {
|
func hydraClientStatus(metadata map[string]any) string {
|
||||||
if metadata == nil {
|
if metadata == nil {
|
||||||
return "active"
|
return "active"
|
||||||
}
|
}
|
||||||
@@ -8142,7 +8135,7 @@ func hydraClientStatus(metadata map[string]interface{}) string {
|
|||||||
return "active"
|
return "active"
|
||||||
}
|
}
|
||||||
|
|
||||||
func extractHydraClientLogo(metadata map[string]interface{}) string {
|
func extractHydraClientLogo(metadata map[string]any) string {
|
||||||
if metadata == nil {
|
if metadata == nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
@@ -8198,7 +8191,7 @@ func resolveLinkedRPURL(clientID string, clientURI string, redirectURIs []string
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolveLinkedRPAutoLoginSupported(clientID string, metadata map[string]interface{}) bool {
|
func resolveLinkedRPAutoLoginSupported(clientID string, metadata map[string]any) bool {
|
||||||
if readMetadataBoolValue(metadata, domain.MetadataAutoLoginSupported) {
|
if readMetadataBoolValue(metadata, domain.MetadataAutoLoginSupported) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -8210,7 +8203,7 @@ func resolveLinkedRPAutoLoginSupported(clientID string, metadata map[string]inte
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolveLinkedRPAutoLoginURL(clientID string, metadata map[string]interface{}) string {
|
func resolveLinkedRPAutoLoginURL(clientID string, metadata map[string]any) string {
|
||||||
clientID = strings.TrimSpace(clientID)
|
clientID = strings.TrimSpace(clientID)
|
||||||
if metadataURL := readMetadataStringValue(metadata, domain.MetadataAutoLoginURL); metadataURL != "" {
|
if metadataURL := readMetadataStringValue(metadata, domain.MetadataAutoLoginURL); metadataURL != "" {
|
||||||
if clientID == "orgfront" {
|
if clientID == "orgfront" {
|
||||||
@@ -8253,7 +8246,7 @@ func ensureOrgfrontAutoLoginURL(rawURL string) string {
|
|||||||
return parsed.String()
|
return parsed.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolveLinkedRPInitURL(clientID string, metadata map[string]interface{}) string {
|
func resolveLinkedRPInitURL(clientID string, metadata map[string]any) string {
|
||||||
if !resolveLinkedRPAutoLoginSupported(clientID, metadata) {
|
if !resolveLinkedRPAutoLoginSupported(clientID, metadata) {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
@@ -8548,7 +8541,7 @@ func (h *AuthHandler) ListRpHistory(c *fiber.Ctx) error {
|
|||||||
ts := log.Timestamp
|
ts := log.Timestamp
|
||||||
item.LastApprovedAt = &ts
|
item.LastApprovedAt = &ts
|
||||||
|
|
||||||
if scopesRaw, ok := details["scopes"].([]interface{}); ok {
|
if scopesRaw, ok := details["scopes"].([]any); ok {
|
||||||
scopes := make([]string, 0, len(scopesRaw))
|
scopes := make([]string, 0, len(scopesRaw))
|
||||||
for _, s := range scopesRaw {
|
for _, s := range scopesRaw {
|
||||||
if str, ok := s.(string); ok {
|
if str, ok := s.(string); ok {
|
||||||
@@ -8811,7 +8804,7 @@ func extractStringLikeValue(raw any) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func extractHydraSessionID(ext map[string]interface{}) string {
|
func extractHydraSessionID(ext map[string]any) string {
|
||||||
if len(ext) == 0 {
|
if len(ext) == 0 {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
@@ -8886,13 +8879,7 @@ func (h *AuthHandler) loadSessionClientBindings(ctx context.Context, userID stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
existing := bindings[sessionID]
|
existing := bindings[sessionID]
|
||||||
seen := false
|
seen := slices.Contains(existing, clientID)
|
||||||
for _, candidate := range existing {
|
|
||||||
if candidate == clientID {
|
|
||||||
seen = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !seen {
|
if !seen {
|
||||||
bindings[sessionID] = append(existing, clientID)
|
bindings[sessionID] = append(existing, clientID)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/mock"
|
"github.com/stretchr/testify/mock"
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// --- Async Test Mocks ---
|
// --- Async Test Mocks ---
|
||||||
@@ -133,6 +134,10 @@ func (m *AsyncMockUserRepo) CountByCompanyCodes(ctx context.Context, codes []str
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *AsyncMockUserRepo) DB() *gorm.DB {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (m *AsyncMockUserRepo) UpdateUserLoginIDs(ctx context.Context, userID string, loginIDs []domain.UserLoginID) error {
|
func (m *AsyncMockUserRepo) UpdateUserLoginIDs(ctx context.Context, userID string, loginIDs []domain.UserLoginID) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ func TestRevokeLinkedRp_Success(t *testing.T) {
|
|||||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
// 1. Kratos whoami
|
// 1. Kratos whoami
|
||||||
if r.URL.Path == "/sessions/whoami" {
|
if r.URL.Path == "/sessions/whoami" {
|
||||||
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
|
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||||
"identity": map[string]interface{}{"id": "user-123"},
|
"identity": map[string]any{"id": "user-123"},
|
||||||
}), nil
|
}), nil
|
||||||
}
|
}
|
||||||
// 2. Hydra Revoke
|
// 2. Hydra Revoke
|
||||||
@@ -75,15 +75,15 @@ func TestRevokeLinkedRp_SendsBackchannelLogoutTokenWhenConfigured(t *testing.T)
|
|||||||
var receivedBody string
|
var receivedBody string
|
||||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
if r.URL.Path == "/sessions/whoami" {
|
if r.URL.Path == "/sessions/whoami" {
|
||||||
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
|
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||||
"identity": map[string]interface{}{"id": "user-123"},
|
"identity": map[string]any{"id": "user-123"},
|
||||||
}), nil
|
}), nil
|
||||||
}
|
}
|
||||||
if r.URL.Host == "hydra.test" && r.Method == http.MethodDelete && r.URL.Path == "/oauth2/auth/sessions/consent" {
|
if r.URL.Host == "hydra.test" && r.Method == http.MethodDelete && r.URL.Path == "/oauth2/auth/sessions/consent" {
|
||||||
return httpResponse(r, http.StatusNoContent, ""), nil
|
return httpResponse(r, http.StatusNoContent, ""), nil
|
||||||
}
|
}
|
||||||
if r.URL.Host == "hydra.test" && r.Method == http.MethodGet && r.URL.Path == "/clients/app-1" {
|
if r.URL.Host == "hydra.test" && r.Method == http.MethodGet && r.URL.Path == "/clients/app-1" {
|
||||||
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
|
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||||
"client_id": "app-1",
|
"client_id": "app-1",
|
||||||
"backchannel_logout_uri": "https://rp.example.com/backchannel-logout",
|
"backchannel_logout_uri": "https://rp.example.com/backchannel-logout",
|
||||||
}), nil
|
}), nil
|
||||||
@@ -158,8 +158,8 @@ func TestListRpHistory_Aggregation(t *testing.T) {
|
|||||||
app.Get("/api/v1/user/rp/history", h.ListRpHistory)
|
app.Get("/api/v1/user/rp/history", h.ListRpHistory)
|
||||||
|
|
||||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
|
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||||
"identity": map[string]interface{}{"id": "user-123"},
|
"identity": map[string]any{"id": "user-123"},
|
||||||
}), nil
|
}), nil
|
||||||
})
|
})
|
||||||
http.DefaultClient = &http.Client{Transport: transport}
|
http.DefaultClient = &http.Client{Transport: transport}
|
||||||
|
|||||||
@@ -39,11 +39,11 @@ func (m *MockKratosAdminServiceForConsent) ListIdentities(ctx context.Context) (
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockKratosAdminServiceForConsent) UpdateIdentity(ctx context.Context, identityID string, traits map[string]interface{}, state string) (*service.KratosIdentity, error) {
|
func (m *MockKratosAdminServiceForConsent) UpdateIdentity(ctx context.Context, identityID string, traits map[string]any, state string) (*service.KratosIdentity, error) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockKratosAdminServiceForConsent) CreateIdentity(ctx context.Context, traits map[string]interface{}) (*service.KratosIdentity, error) {
|
func (m *MockKratosAdminServiceForConsent) CreateIdentity(ctx context.Context, traits map[string]any) (*service.KratosIdentity, error) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,12 +146,12 @@ func TestGetConsentRequest_Normal(t *testing.T) {
|
|||||||
// Mock Hydra transport
|
// Mock Hydra transport
|
||||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-123" {
|
if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-123" {
|
||||||
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
|
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||||
"challenge": "challenge-123",
|
"challenge": "challenge-123",
|
||||||
"requested_scope": []string{"openid", "profile"},
|
"requested_scope": []string{"openid", "profile"},
|
||||||
"skip": false,
|
"skip": false,
|
||||||
"subject": "user-123",
|
"subject": "user-123",
|
||||||
"client": map[string]interface{}{
|
"client": map[string]any{
|
||||||
"client_id": "client-app",
|
"client_id": "client-app",
|
||||||
"client_name": "Test App",
|
"client_name": "Test App",
|
||||||
},
|
},
|
||||||
@@ -180,7 +180,7 @@ func TestGetConsentRequest_Normal(t *testing.T) {
|
|||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var body map[string]interface{}
|
var body map[string]any
|
||||||
json.NewDecoder(resp.Body).Decode(&body)
|
json.NewDecoder(resp.Body).Decode(&body)
|
||||||
|
|
||||||
assert.Equal(t, "challenge-123", body["challenge"])
|
assert.Equal(t, "challenge-123", body["challenge"])
|
||||||
@@ -190,12 +190,12 @@ func TestGetConsentRequest_Normal(t *testing.T) {
|
|||||||
func TestGetConsentRequest_AddsMandatoryTenantScope(t *testing.T) {
|
func TestGetConsentRequest_AddsMandatoryTenantScope(t *testing.T) {
|
||||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-tenant-scope" {
|
if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-tenant-scope" {
|
||||||
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
|
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||||
"challenge": "challenge-tenant-scope",
|
"challenge": "challenge-tenant-scope",
|
||||||
"requested_scope": []string{"openid", "profile"},
|
"requested_scope": []string{"openid", "profile"},
|
||||||
"skip": false,
|
"skip": false,
|
||||||
"subject": "user-123",
|
"subject": "user-123",
|
||||||
"client": map[string]interface{}{
|
"client": map[string]any{
|
||||||
"client_id": "client-app",
|
"client_id": "client-app",
|
||||||
"client_name": "Test App",
|
"client_name": "Test App",
|
||||||
"metadata": map[string]any{
|
"metadata": map[string]any{
|
||||||
@@ -224,7 +224,7 @@ func TestGetConsentRequest_AddsMandatoryTenantScope(t *testing.T) {
|
|||||||
// Mock profile resolution to allow tenant access
|
// Mock profile resolution to allow tenant access
|
||||||
mockKratosAdmin.On("GetIdentity", mock.Anything, "user-123").Return(&service.KratosIdentity{
|
mockKratosAdmin.On("GetIdentity", mock.Anything, "user-123").Return(&service.KratosIdentity{
|
||||||
ID: "user-123",
|
ID: "user-123",
|
||||||
Traits: map[string]interface{}{
|
Traits: map[string]any{
|
||||||
"email": "user@example.com",
|
"email": "user@example.com",
|
||||||
},
|
},
|
||||||
}, nil)
|
}, nil)
|
||||||
@@ -259,12 +259,12 @@ func TestGetConsentRequest_AddsMandatoryTenantScope(t *testing.T) {
|
|||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var body map[string]interface{}
|
var body map[string]any
|
||||||
json.NewDecoder(resp.Body).Decode(&body)
|
json.NewDecoder(resp.Body).Decode(&body)
|
||||||
|
|
||||||
assert.Equal(t, []interface{}{"openid", "tenant", "profile"}, body["requested_scope"])
|
assert.Equal(t, []any{"openid", "tenant", "profile"}, body["requested_scope"])
|
||||||
scopeDetails := body["scope_details"].(map[string]interface{})
|
scopeDetails := body["scope_details"].(map[string]any)
|
||||||
tenantDetail := scopeDetails["tenant"].(map[string]interface{})
|
tenantDetail := scopeDetails["tenant"].(map[string]any)
|
||||||
assert.Equal(t, true, tenantDetail["mandatory"])
|
assert.Equal(t, true, tenantDetail["mandatory"])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,28 +272,28 @@ func TestGetConsentRequest_Skip_AutoAccept(t *testing.T) {
|
|||||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
// Hydra: Get Consent Request
|
// Hydra: Get Consent Request
|
||||||
if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-skip" {
|
if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-skip" {
|
||||||
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
|
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||||
"challenge": "challenge-skip",
|
"challenge": "challenge-skip",
|
||||||
"requested_scope": []string{"openid"},
|
"requested_scope": []string{"openid"},
|
||||||
"skip": true,
|
"skip": true,
|
||||||
"subject": "user-123",
|
"subject": "user-123",
|
||||||
"client": map[string]interface{}{
|
"client": map[string]any{
|
||||||
"client_id": "client-app",
|
"client_id": "client-app",
|
||||||
},
|
},
|
||||||
}), nil
|
}), nil
|
||||||
}
|
}
|
||||||
// Kratos: Get Identity
|
// Kratos: Get Identity
|
||||||
if r.URL.Path == "/admin/identities/user-123" {
|
if r.URL.Path == "/admin/identities/user-123" {
|
||||||
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
|
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||||
"id": "user-123",
|
"id": "user-123",
|
||||||
"traits": map[string]interface{}{
|
"traits": map[string]any{
|
||||||
"email": "user@test.com",
|
"email": "user@test.com",
|
||||||
},
|
},
|
||||||
}), nil
|
}), nil
|
||||||
}
|
}
|
||||||
// Hydra: Accept Consent Request
|
// Hydra: Accept Consent Request
|
||||||
if r.URL.Path == "/oauth2/auth/requests/consent/accept" && r.URL.Query().Get("consent_challenge") == "challenge-skip" {
|
if r.URL.Path == "/oauth2/auth/requests/consent/accept" && r.URL.Query().Get("consent_challenge") == "challenge-skip" {
|
||||||
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
|
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||||
"redirect_to": "http://rp/cb",
|
"redirect_to": "http://rp/cb",
|
||||||
}), nil
|
}), nil
|
||||||
}
|
}
|
||||||
@@ -320,7 +320,7 @@ func TestGetConsentRequest_Skip_AutoAccept(t *testing.T) {
|
|||||||
}
|
}
|
||||||
mockKratosAdmin.On("GetIdentity", mock.Anything, "user-123").Return(&service.KratosIdentity{
|
mockKratosAdmin.On("GetIdentity", mock.Anything, "user-123").Return(&service.KratosIdentity{
|
||||||
ID: "user-123",
|
ID: "user-123",
|
||||||
Traits: map[string]interface{}{
|
Traits: map[string]any{
|
||||||
"email": "user@test.com",
|
"email": "user@test.com",
|
||||||
},
|
},
|
||||||
}, nil)
|
}, nil)
|
||||||
@@ -332,7 +332,7 @@ func TestGetConsentRequest_Skip_AutoAccept(t *testing.T) {
|
|||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var body map[string]interface{}
|
var body map[string]any
|
||||||
json.NewDecoder(resp.Body).Decode(&body)
|
json.NewDecoder(resp.Body).Decode(&body)
|
||||||
assert.Equal(t, "http://rp/cb", body["redirectTo"])
|
assert.Equal(t, "http://rp/cb", body["redirectTo"])
|
||||||
assert.Equal(t, 1, len(rpUsageSink.events))
|
assert.Equal(t, 1, len(rpUsageSink.events))
|
||||||
@@ -345,26 +345,26 @@ func TestGetConsentRequest_Skip_AutoAccept(t *testing.T) {
|
|||||||
func TestAcceptConsentRequest_Normal(t *testing.T) {
|
func TestAcceptConsentRequest_Normal(t *testing.T) {
|
||||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-accept" {
|
if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-accept" {
|
||||||
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
|
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||||
"challenge": "challenge-accept",
|
"challenge": "challenge-accept",
|
||||||
"requested_scope": []string{"openid", "profile"},
|
"requested_scope": []string{"openid", "profile"},
|
||||||
"subject": "user-123",
|
"subject": "user-123",
|
||||||
"client": map[string]interface{}{
|
"client": map[string]any{
|
||||||
"client_id": "client-app",
|
"client_id": "client-app",
|
||||||
"client_name": "Test App",
|
"client_name": "Test App",
|
||||||
},
|
},
|
||||||
}), nil
|
}), nil
|
||||||
}
|
}
|
||||||
if r.URL.Path == "/admin/identities/user-123" {
|
if r.URL.Path == "/admin/identities/user-123" {
|
||||||
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
|
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||||
"id": "user-123",
|
"id": "user-123",
|
||||||
"traits": map[string]interface{}{
|
"traits": map[string]any{
|
||||||
"email": "user@test.com",
|
"email": "user@test.com",
|
||||||
},
|
},
|
||||||
}), nil
|
}), nil
|
||||||
}
|
}
|
||||||
if r.URL.Path == "/oauth2/auth/requests/consent/accept" && r.URL.Query().Get("consent_challenge") == "challenge-accept" {
|
if r.URL.Path == "/oauth2/auth/requests/consent/accept" && r.URL.Query().Get("consent_challenge") == "challenge-accept" {
|
||||||
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
|
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||||
"redirect_to": "http://rp/cb",
|
"redirect_to": "http://rp/cb",
|
||||||
}), nil
|
}), nil
|
||||||
}
|
}
|
||||||
@@ -393,14 +393,14 @@ func TestAcceptConsentRequest_Normal(t *testing.T) {
|
|||||||
}
|
}
|
||||||
mockKratosAdmin.On("GetIdentity", mock.Anything, "user-123").Return(&service.KratosIdentity{
|
mockKratosAdmin.On("GetIdentity", mock.Anything, "user-123").Return(&service.KratosIdentity{
|
||||||
ID: "user-123",
|
ID: "user-123",
|
||||||
Traits: map[string]interface{}{
|
Traits: map[string]any{
|
||||||
"email": "user@test.com",
|
"email": "user@test.com",
|
||||||
},
|
},
|
||||||
}, nil)
|
}, nil)
|
||||||
|
|
||||||
app := newConsentTestApp(h)
|
app := newConsentTestApp(h)
|
||||||
|
|
||||||
body, _ := json.Marshal(map[string]interface{}{
|
body, _ := json.Marshal(map[string]any{
|
||||||
"consent_challenge": "challenge-accept",
|
"consent_challenge": "challenge-accept",
|
||||||
"grant_scope": []string{"openid"},
|
"grant_scope": []string{"openid"},
|
||||||
})
|
})
|
||||||
@@ -419,7 +419,7 @@ func TestAcceptConsentRequest_Normal(t *testing.T) {
|
|||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, "client-app", auditDetails["client_id"])
|
assert.Equal(t, "client-app", auditDetails["client_id"])
|
||||||
assert.Equal(t, "Test App", auditDetails["client_name"])
|
assert.Equal(t, "Test App", auditDetails["client_name"])
|
||||||
assert.Equal(t, []interface{}{"openid"}, auditDetails["scopes"])
|
assert.Equal(t, []any{"openid"}, auditDetails["scopes"])
|
||||||
assert.Equal(t, 1, len(rpUsageSink.events))
|
assert.Equal(t, 1, len(rpUsageSink.events))
|
||||||
assert.Equal(t, domain.RPUsageEventTypeAuthorizationGranted, rpUsageSink.events[0].EventType)
|
assert.Equal(t, domain.RPUsageEventTypeAuthorizationGranted, rpUsageSink.events[0].EventType)
|
||||||
assert.Equal(t, "user-123", rpUsageSink.events[0].Subject)
|
assert.Equal(t, "user-123", rpUsageSink.events[0].Subject)
|
||||||
@@ -436,11 +436,11 @@ func TestAcceptConsentRequest_EnforcesMandatoryTenantScope(t *testing.T) {
|
|||||||
|
|
||||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-tenant-accept" {
|
if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-tenant-accept" {
|
||||||
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
|
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||||
"challenge": "challenge-tenant-accept",
|
"challenge": "challenge-tenant-accept",
|
||||||
"requested_scope": []string{"openid", "profile"},
|
"requested_scope": []string{"openid", "profile"},
|
||||||
"subject": "user-123",
|
"subject": "user-123",
|
||||||
"client": map[string]interface{}{
|
"client": map[string]any{
|
||||||
"client_id": "client-app",
|
"client_id": "client-app",
|
||||||
"metadata": map[string]any{
|
"metadata": map[string]any{
|
||||||
"tenant_id": "tenant-abc",
|
"tenant_id": "tenant-abc",
|
||||||
@@ -456,9 +456,9 @@ func TestAcceptConsentRequest_EnforcesMandatoryTenantScope(t *testing.T) {
|
|||||||
}), nil
|
}), nil
|
||||||
}
|
}
|
||||||
if r.URL.Path == "/admin/identities/user-123" {
|
if r.URL.Path == "/admin/identities/user-123" {
|
||||||
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
|
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||||
"id": "user-123",
|
"id": "user-123",
|
||||||
"traits": map[string]interface{}{
|
"traits": map[string]any{
|
||||||
"email": "user@test.com",
|
"email": "user@test.com",
|
||||||
},
|
},
|
||||||
}), nil
|
}), nil
|
||||||
@@ -466,10 +466,10 @@ func TestAcceptConsentRequest_EnforcesMandatoryTenantScope(t *testing.T) {
|
|||||||
if r.URL.Path == "/oauth2/auth/requests/consent/accept" && r.URL.Query().Get("consent_challenge") == "challenge-tenant-accept" {
|
if r.URL.Path == "/oauth2/auth/requests/consent/accept" && r.URL.Query().Get("consent_challenge") == "challenge-tenant-accept" {
|
||||||
var payload map[string]any
|
var payload map[string]any
|
||||||
assert.NoError(t, json.NewDecoder(r.Body).Decode(&payload))
|
assert.NoError(t, json.NewDecoder(r.Body).Decode(&payload))
|
||||||
for _, scope := range payload["grant_scope"].([]interface{}) {
|
for _, scope := range payload["grant_scope"].([]any) {
|
||||||
capturedGrantScopes = append(capturedGrantScopes, scope.(string))
|
capturedGrantScopes = append(capturedGrantScopes, scope.(string))
|
||||||
}
|
}
|
||||||
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
|
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||||
"redirect_to": "http://rp/cb",
|
"redirect_to": "http://rp/cb",
|
||||||
}), nil
|
}), nil
|
||||||
}
|
}
|
||||||
@@ -492,14 +492,14 @@ func TestAcceptConsentRequest_EnforcesMandatoryTenantScope(t *testing.T) {
|
|||||||
}
|
}
|
||||||
mockKratosAdmin.On("GetIdentity", mock.Anything, "user-123").Return(&service.KratosIdentity{
|
mockKratosAdmin.On("GetIdentity", mock.Anything, "user-123").Return(&service.KratosIdentity{
|
||||||
ID: "user-123",
|
ID: "user-123",
|
||||||
Traits: map[string]interface{}{
|
Traits: map[string]any{
|
||||||
"email": "user@test.com",
|
"email": "user@test.com",
|
||||||
},
|
},
|
||||||
}, nil)
|
}, nil)
|
||||||
|
|
||||||
app := newConsentTestApp(h)
|
app := newConsentTestApp(h)
|
||||||
|
|
||||||
body, _ := json.Marshal(map[string]interface{}{
|
body, _ := json.Marshal(map[string]any{
|
||||||
"consent_challenge": "challenge-tenant-accept",
|
"consent_challenge": "challenge-tenant-accept",
|
||||||
"grant_scope": []string{"openid", "profile"},
|
"grant_scope": []string{"openid", "profile"},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -17,15 +17,15 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestBuildOidcClaimsFromTraits_DynamicClaims(t *testing.T) {
|
func TestBuildOidcClaimsFromTraits_DynamicClaims(t *testing.T) {
|
||||||
traits := map[string]interface{}{
|
traits := map[string]any{
|
||||||
"email": "user@baron.com",
|
"email": "user@baron.com",
|
||||||
"name": "홍길동",
|
"name": "홍길동",
|
||||||
"tenant_id": "primary-tenant-999", // Added primary tenant
|
"tenant_id": "primary-tenant-999", // Added primary tenant
|
||||||
"tenant-1": map[string]interface{}{
|
"tenant-1": map[string]any{
|
||||||
"department": "개발팀",
|
"department": "개발팀",
|
||||||
"grade": "선임",
|
"grade": "선임",
|
||||||
},
|
},
|
||||||
"tenant-2": map[string]interface{}{
|
"tenant-2": map[string]any{
|
||||||
"department": "재무팀",
|
"department": "재무팀",
|
||||||
"grade": "팀장",
|
"grade": "팀장",
|
||||||
},
|
},
|
||||||
@@ -130,18 +130,18 @@ func TestRepresentativeTenantIDFromTraits(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAcceptConsentRequest_DynamicClaims(t *testing.T) {
|
func TestAcceptConsentRequest_DynamicClaims(t *testing.T) {
|
||||||
var capturedClaims map[string]interface{}
|
var capturedClaims map[string]any
|
||||||
|
|
||||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
// Hydra: Get Consent Request
|
// Hydra: Get Consent Request
|
||||||
if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-dynamic" {
|
if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-dynamic" {
|
||||||
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
|
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||||
"challenge": "challenge-dynamic",
|
"challenge": "challenge-dynamic",
|
||||||
"requested_scope": []string{"openid", "profile", "tenant"},
|
"requested_scope": []string{"openid", "profile", "tenant"},
|
||||||
"subject": "user-123",
|
"subject": "user-123",
|
||||||
"client": map[string]interface{}{
|
"client": map[string]any{
|
||||||
"client_id": "client-app",
|
"client_id": "client-app",
|
||||||
"metadata": map[string]interface{}{
|
"metadata": map[string]any{
|
||||||
"tenant_id": "tenant-abc",
|
"tenant_id": "tenant-abc",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -149,12 +149,12 @@ func TestAcceptConsentRequest_DynamicClaims(t *testing.T) {
|
|||||||
}
|
}
|
||||||
// Kratos: Get Identity
|
// Kratos: Get Identity
|
||||||
if r.URL.Path == "/admin/identities/user-123" {
|
if r.URL.Path == "/admin/identities/user-123" {
|
||||||
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
|
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||||
"id": "user-123",
|
"id": "user-123",
|
||||||
"traits": map[string]interface{}{
|
"traits": map[string]any{
|
||||||
"email": "user@test.com",
|
"email": "user@test.com",
|
||||||
"name": "Test User",
|
"name": "Test User",
|
||||||
"tenant-abc": map[string]interface{}{
|
"tenant-abc": map[string]any{
|
||||||
"department": "Innovation",
|
"department": "Innovation",
|
||||||
"position": "Architect",
|
"position": "Architect",
|
||||||
},
|
},
|
||||||
@@ -165,13 +165,13 @@ func TestAcceptConsentRequest_DynamicClaims(t *testing.T) {
|
|||||||
if r.URL.Path == "/oauth2/auth/requests/consent/accept" && r.URL.Query().Get("consent_challenge") == "challenge-dynamic" {
|
if r.URL.Path == "/oauth2/auth/requests/consent/accept" && r.URL.Query().Get("consent_challenge") == "challenge-dynamic" {
|
||||||
// Capture the claims sent to Hydra
|
// Capture the claims sent to Hydra
|
||||||
body, _ := io.ReadAll(r.Body)
|
body, _ := io.ReadAll(r.Body)
|
||||||
var acceptReq map[string]interface{}
|
var acceptReq map[string]any
|
||||||
json.Unmarshal(body, &acceptReq)
|
json.Unmarshal(body, &acceptReq)
|
||||||
if session, ok := acceptReq["session"].(map[string]interface{}); ok {
|
if session, ok := acceptReq["session"].(map[string]any); ok {
|
||||||
capturedClaims = session["id_token"].(map[string]interface{})
|
capturedClaims = session["id_token"].(map[string]any)
|
||||||
}
|
}
|
||||||
|
|
||||||
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
|
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||||
"redirect_to": "http://rp/cb",
|
"redirect_to": "http://rp/cb",
|
||||||
}), nil
|
}), nil
|
||||||
}
|
}
|
||||||
@@ -192,10 +192,10 @@ func TestAcceptConsentRequest_DynamicClaims(t *testing.T) {
|
|||||||
}
|
}
|
||||||
h.KratosAdmin.(*MockKratosAdminService).On("GetIdentity", mock.Anything, "user-123").Return(&service.KratosIdentity{
|
h.KratosAdmin.(*MockKratosAdminService).On("GetIdentity", mock.Anything, "user-123").Return(&service.KratosIdentity{
|
||||||
ID: "user-123",
|
ID: "user-123",
|
||||||
Traits: map[string]interface{}{
|
Traits: map[string]any{
|
||||||
"email": "user@test.com",
|
"email": "user@test.com",
|
||||||
"name": "Test User",
|
"name": "Test User",
|
||||||
"tenant-abc": map[string]interface{}{
|
"tenant-abc": map[string]any{
|
||||||
"department": "Innovation",
|
"department": "Innovation",
|
||||||
"position": "Architect",
|
"position": "Architect",
|
||||||
},
|
},
|
||||||
@@ -205,7 +205,7 @@ func TestAcceptConsentRequest_DynamicClaims(t *testing.T) {
|
|||||||
app := fiber.New()
|
app := fiber.New()
|
||||||
app.Post("/api/v1/auth/consent/accept", h.AcceptConsentRequest)
|
app.Post("/api/v1/auth/consent/accept", h.AcceptConsentRequest)
|
||||||
|
|
||||||
reqBody, _ := json.Marshal(map[string]interface{}{
|
reqBody, _ := json.Marshal(map[string]any{
|
||||||
"consent_challenge": "challenge-dynamic",
|
"consent_challenge": "challenge-dynamic",
|
||||||
"grant_scope": []string{"openid", "profile", "tenant"},
|
"grant_scope": []string{"openid", "profile", "tenant"},
|
||||||
})
|
})
|
||||||
@@ -225,20 +225,20 @@ func TestAcceptConsentRequest_DynamicClaims(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAcceptConsentRequest_UsesRepresentativeTenantIDInsteadOfClientTenantContext(t *testing.T) {
|
func TestAcceptConsentRequest_UsesRepresentativeTenantIDInsteadOfClientTenantContext(t *testing.T) {
|
||||||
var capturedClaims map[string]interface{}
|
var capturedClaims map[string]any
|
||||||
|
|
||||||
representativeTenantID := "01970f0a-5c28-74d8-a73a-f6e9e9a7b210"
|
representativeTenantID := "01970f0a-5c28-74d8-a73a-f6e9e9a7b210"
|
||||||
rpContextTenantID := "01970f0b-3448-7bb8-bdc7-16b6a1d2e661"
|
rpContextTenantID := "01970f0b-3448-7bb8-bdc7-16b6a1d2e661"
|
||||||
|
|
||||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-representative-tenant" {
|
if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-representative-tenant" {
|
||||||
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
|
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||||
"challenge": "challenge-representative-tenant",
|
"challenge": "challenge-representative-tenant",
|
||||||
"requested_scope": []string{"openid", "profile", "tenant"},
|
"requested_scope": []string{"openid", "profile", "tenant"},
|
||||||
"subject": "user-representative",
|
"subject": "user-representative",
|
||||||
"client": map[string]interface{}{
|
"client": map[string]any{
|
||||||
"client_id": "client-app",
|
"client_id": "client-app",
|
||||||
"metadata": map[string]interface{}{
|
"metadata": map[string]any{
|
||||||
"tenant_id": rpContextTenantID,
|
"tenant_id": rpContextTenantID,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -246,13 +246,13 @@ func TestAcceptConsentRequest_UsesRepresentativeTenantIDInsteadOfClientTenantCon
|
|||||||
}
|
}
|
||||||
if r.URL.Path == "/oauth2/auth/requests/consent/accept" && r.URL.Query().Get("consent_challenge") == "challenge-representative-tenant" {
|
if r.URL.Path == "/oauth2/auth/requests/consent/accept" && r.URL.Query().Get("consent_challenge") == "challenge-representative-tenant" {
|
||||||
body, _ := io.ReadAll(r.Body)
|
body, _ := io.ReadAll(r.Body)
|
||||||
var acceptReq map[string]interface{}
|
var acceptReq map[string]any
|
||||||
json.Unmarshal(body, &acceptReq)
|
json.Unmarshal(body, &acceptReq)
|
||||||
if session, ok := acceptReq["session"].(map[string]interface{}); ok {
|
if session, ok := acceptReq["session"].(map[string]any); ok {
|
||||||
capturedClaims = session["id_token"].(map[string]interface{})
|
capturedClaims = session["id_token"].(map[string]any)
|
||||||
}
|
}
|
||||||
|
|
||||||
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
|
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||||
"redirect_to": "http://rp/cb",
|
"redirect_to": "http://rp/cb",
|
||||||
}), nil
|
}), nil
|
||||||
}
|
}
|
||||||
@@ -269,12 +269,12 @@ func TestAcceptConsentRequest_UsesRepresentativeTenantIDInsteadOfClientTenantCon
|
|||||||
}
|
}
|
||||||
h.KratosAdmin.(*MockKratosAdminService).On("GetIdentity", mock.Anything, "user-representative").Return(&service.KratosIdentity{
|
h.KratosAdmin.(*MockKratosAdminService).On("GetIdentity", mock.Anything, "user-representative").Return(&service.KratosIdentity{
|
||||||
ID: "user-representative",
|
ID: "user-representative",
|
||||||
Traits: map[string]interface{}{
|
Traits: map[string]any{
|
||||||
"email": "user@test.com",
|
"email": "user@test.com",
|
||||||
"name": "Test User",
|
"name": "Test User",
|
||||||
"additionalAppointments": []interface{}{
|
"additionalAppointments": []any{
|
||||||
map[string]interface{}{"tenantId": representativeTenantID, "isPrimary": true},
|
map[string]any{"tenantId": representativeTenantID, "isPrimary": true},
|
||||||
map[string]interface{}{"tenantId": rpContextTenantID},
|
map[string]any{"tenantId": rpContextTenantID},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}, nil)
|
}, nil)
|
||||||
@@ -282,7 +282,7 @@ func TestAcceptConsentRequest_UsesRepresentativeTenantIDInsteadOfClientTenantCon
|
|||||||
app := fiber.New()
|
app := fiber.New()
|
||||||
app.Post("/api/v1/auth/consent/accept", h.AcceptConsentRequest)
|
app.Post("/api/v1/auth/consent/accept", h.AcceptConsentRequest)
|
||||||
|
|
||||||
reqBody, _ := json.Marshal(map[string]interface{}{
|
reqBody, _ := json.Marshal(map[string]any{
|
||||||
"consent_challenge": "challenge-representative-tenant",
|
"consent_challenge": "challenge-representative-tenant",
|
||||||
"grant_scope": []string{"openid", "profile"},
|
"grant_scope": []string{"openid", "profile"},
|
||||||
})
|
})
|
||||||
@@ -301,7 +301,7 @@ func TestAcceptConsentRequest_UsesRepresentativeTenantIDInsteadOfClientTenantCon
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAcceptConsentRequest_IncludesHanmacFamilyTenantClaimDetails(t *testing.T) {
|
func TestAcceptConsentRequest_IncludesHanmacFamilyTenantClaimDetails(t *testing.T) {
|
||||||
var capturedClaims map[string]interface{}
|
var capturedClaims map[string]any
|
||||||
deptID := "01970f0a-5c28-74d8-a73a-f6e9e9a7b210"
|
deptID := "01970f0a-5c28-74d8-a73a-f6e9e9a7b210"
|
||||||
secondDeptID := "01970f0b-3448-7bb8-bdc7-16b6a1d2e661"
|
secondDeptID := "01970f0b-3448-7bb8-bdc7-16b6a1d2e661"
|
||||||
companyID := "01970f08-91da-7286-bd19-882fb98d1f2c"
|
companyID := "01970f08-91da-7286-bd19-882fb98d1f2c"
|
||||||
@@ -309,13 +309,13 @@ func TestAcceptConsentRequest_IncludesHanmacFamilyTenantClaimDetails(t *testing.
|
|||||||
|
|
||||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-hanmac-tenant-claim" {
|
if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-hanmac-tenant-claim" {
|
||||||
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
|
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||||
"challenge": "challenge-hanmac-tenant-claim",
|
"challenge": "challenge-hanmac-tenant-claim",
|
||||||
"requested_scope": []string{"openid", "profile", "tenant"},
|
"requested_scope": []string{"openid", "profile", "tenant"},
|
||||||
"subject": "user-hanmac",
|
"subject": "user-hanmac",
|
||||||
"client": map[string]interface{}{
|
"client": map[string]any{
|
||||||
"client_id": "hanmac-rp",
|
"client_id": "hanmac-rp",
|
||||||
"metadata": map[string]interface{}{
|
"metadata": map[string]any{
|
||||||
"tenant_id": deptID,
|
"tenant_id": deptID,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -323,13 +323,13 @@ func TestAcceptConsentRequest_IncludesHanmacFamilyTenantClaimDetails(t *testing.
|
|||||||
}
|
}
|
||||||
if r.URL.Path == "/oauth2/auth/requests/consent/accept" && r.URL.Query().Get("consent_challenge") == "challenge-hanmac-tenant-claim" {
|
if r.URL.Path == "/oauth2/auth/requests/consent/accept" && r.URL.Query().Get("consent_challenge") == "challenge-hanmac-tenant-claim" {
|
||||||
body, _ := io.ReadAll(r.Body)
|
body, _ := io.ReadAll(r.Body)
|
||||||
var acceptReq map[string]interface{}
|
var acceptReq map[string]any
|
||||||
json.Unmarshal(body, &acceptReq)
|
json.Unmarshal(body, &acceptReq)
|
||||||
if session, ok := acceptReq["session"].(map[string]interface{}); ok {
|
if session, ok := acceptReq["session"].(map[string]any); ok {
|
||||||
capturedClaims = session["id_token"].(map[string]interface{})
|
capturedClaims = session["id_token"].(map[string]any)
|
||||||
}
|
}
|
||||||
|
|
||||||
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
|
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||||
"redirect_to": "http://rp/cb",
|
"redirect_to": "http://rp/cb",
|
||||||
}), nil
|
}), nil
|
||||||
}
|
}
|
||||||
@@ -346,11 +346,11 @@ func TestAcceptConsentRequest_IncludesHanmacFamilyTenantClaimDetails(t *testing.
|
|||||||
}
|
}
|
||||||
h.KratosAdmin.(*MockKratosAdminService).On("GetIdentity", mock.Anything, "user-hanmac").Return(&service.KratosIdentity{
|
h.KratosAdmin.(*MockKratosAdminService).On("GetIdentity", mock.Anything, "user-hanmac").Return(&service.KratosIdentity{
|
||||||
ID: "user-hanmac",
|
ID: "user-hanmac",
|
||||||
Traits: map[string]interface{}{
|
Traits: map[string]any{
|
||||||
"email": "hanmac-user@example.com",
|
"email": "hanmac-user@example.com",
|
||||||
"name": "한맥 사용자",
|
"name": "한맥 사용자",
|
||||||
"additionalAppointments": []interface{}{
|
"additionalAppointments": []any{
|
||||||
map[string]interface{}{
|
map[string]any{
|
||||||
"tenantId": deptID,
|
"tenantId": deptID,
|
||||||
"isPrimary": true,
|
"isPrimary": true,
|
||||||
"isOwner": true,
|
"isOwner": true,
|
||||||
@@ -358,7 +358,7 @@ func TestAcceptConsentRequest_IncludesHanmacFamilyTenantClaimDetails(t *testing.
|
|||||||
"jobTitle": "기술기획",
|
"jobTitle": "기술기획",
|
||||||
"position": "팀장",
|
"position": "팀장",
|
||||||
},
|
},
|
||||||
map[string]interface{}{
|
map[string]any{
|
||||||
"tenantId": secondDeptID,
|
"tenantId": secondDeptID,
|
||||||
"isPrimary": false,
|
"isPrimary": false,
|
||||||
"isOwner": false,
|
"isOwner": false,
|
||||||
@@ -404,7 +404,7 @@ func TestAcceptConsentRequest_IncludesHanmacFamilyTenantClaimDetails(t *testing.
|
|||||||
app := fiber.New()
|
app := fiber.New()
|
||||||
app.Post("/api/v1/auth/consent/accept", h.AcceptConsentRequest)
|
app.Post("/api/v1/auth/consent/accept", h.AcceptConsentRequest)
|
||||||
|
|
||||||
reqBody, _ := json.Marshal(map[string]interface{}{
|
reqBody, _ := json.Marshal(map[string]any{
|
||||||
"consent_challenge": "challenge-hanmac-tenant-claim",
|
"consent_challenge": "challenge-hanmac-tenant-claim",
|
||||||
"grant_scope": []string{"openid", "profile", "tenant"},
|
"grant_scope": []string{"openid", "profile", "tenant"},
|
||||||
})
|
})
|
||||||
@@ -416,10 +416,10 @@ func TestAcceptConsentRequest_IncludesHanmacFamilyTenantClaimDetails(t *testing.
|
|||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
assert.NotNil(t, capturedClaims)
|
assert.NotNil(t, capturedClaims)
|
||||||
assert.Equal(t, []interface{}{deptID}, capturedClaims["lead_tenants"])
|
assert.Equal(t, []any{deptID}, capturedClaims["lead_tenants"])
|
||||||
assert.ElementsMatch(t, []interface{}{deptID, secondDeptID}, capturedClaims["joined_tenants"])
|
assert.ElementsMatch(t, []any{deptID, secondDeptID}, capturedClaims["joined_tenants"])
|
||||||
tenants := capturedClaims["tenants"].(map[string]interface{})
|
tenants := capturedClaims["tenants"].(map[string]any)
|
||||||
dept := tenants[deptID].(map[string]interface{})
|
dept := tenants[deptID].(map[string]any)
|
||||||
assert.Equal(t, true, dept["lead"])
|
assert.Equal(t, true, dept["lead"])
|
||||||
assert.Equal(t, true, dept["representative"])
|
assert.Equal(t, true, dept["representative"])
|
||||||
assert.Equal(t, "책임", dept["grade"])
|
assert.Equal(t, "책임", dept["grade"])
|
||||||
@@ -428,21 +428,21 @@ func TestAcceptConsentRequest_IncludesHanmacFamilyTenantClaimDetails(t *testing.
|
|||||||
assert.Equal(t, companyID, dept["parentTenantId"])
|
assert.Equal(t, companyID, dept["parentTenantId"])
|
||||||
assert.NotContains(t, dept, "parentTenant")
|
assert.NotContains(t, dept, "parentTenant")
|
||||||
|
|
||||||
ancestors := dept["ancestors"].([]interface{})
|
ancestors := dept["ancestors"].([]any)
|
||||||
assert.Len(t, ancestors, 2)
|
assert.Len(t, ancestors, 2)
|
||||||
companyAncestor := ancestors[0].(map[string]interface{})
|
companyAncestor := ancestors[0].(map[string]any)
|
||||||
assert.Equal(t, companyID, companyAncestor["id"])
|
assert.Equal(t, companyID, companyAncestor["id"])
|
||||||
assert.Equal(t, "hanmac", companyAncestor["slug"])
|
assert.Equal(t, "hanmac", companyAncestor["slug"])
|
||||||
assert.Equal(t, rootID, companyAncestor["parentTenantId"])
|
assert.Equal(t, rootID, companyAncestor["parentTenantId"])
|
||||||
assert.NotContains(t, companyAncestor, "parentTenant")
|
assert.NotContains(t, companyAncestor, "parentTenant")
|
||||||
rootAncestor := ancestors[1].(map[string]interface{})
|
rootAncestor := ancestors[1].(map[string]any)
|
||||||
assert.Equal(t, rootID, rootAncestor["id"])
|
assert.Equal(t, rootID, rootAncestor["id"])
|
||||||
assert.Equal(t, "hanmac-family", rootAncestor["slug"])
|
assert.Equal(t, "hanmac-family", rootAncestor["slug"])
|
||||||
assert.Contains(t, rootAncestor, "parentTenantId")
|
assert.Contains(t, rootAncestor, "parentTenantId")
|
||||||
assert.Nil(t, rootAncestor["parentTenantId"])
|
assert.Nil(t, rootAncestor["parentTenantId"])
|
||||||
assert.NotContains(t, rootAncestor, "parentTenant")
|
assert.NotContains(t, rootAncestor, "parentTenant")
|
||||||
|
|
||||||
secondDept := tenants[secondDeptID].(map[string]interface{})
|
secondDept := tenants[secondDeptID].(map[string]any)
|
||||||
assert.Equal(t, false, secondDept["lead"])
|
assert.Equal(t, false, secondDept["lead"])
|
||||||
assert.Equal(t, false, secondDept["representative"])
|
assert.Equal(t, false, secondDept["representative"])
|
||||||
assert.Equal(t, "선임", secondDept["grade"])
|
assert.Equal(t, "선임", secondDept["grade"])
|
||||||
@@ -512,18 +512,18 @@ func TestWithHanmacFamilyTenantClaims_DefaultClaimsOnlyWithoutTenantScope(t *tes
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAcceptConsentRequest_IncludesRPProfileClaims(t *testing.T) {
|
func TestAcceptConsentRequest_IncludesRPProfileClaims(t *testing.T) {
|
||||||
var capturedClaims map[string]interface{}
|
var capturedClaims map[string]any
|
||||||
|
|
||||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-rp-profile" {
|
if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-rp-profile" {
|
||||||
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
|
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||||
"challenge": "challenge-rp-profile",
|
"challenge": "challenge-rp-profile",
|
||||||
"requested_scope": []string{"openid", "profile", "tenant"},
|
"requested_scope": []string{"openid", "profile", "tenant"},
|
||||||
"subject": "user-123",
|
"subject": "user-123",
|
||||||
"client": map[string]interface{}{
|
"client": map[string]any{
|
||||||
"client_id": "client-app",
|
"client_id": "client-app",
|
||||||
"metadata": map[string]interface{}{
|
"metadata": map[string]any{
|
||||||
"customUserSchema": []map[string]interface{}{
|
"customUserSchema": []map[string]any{
|
||||||
{
|
{
|
||||||
"key": "approvalLevel",
|
"key": "approvalLevel",
|
||||||
"label": "승인 등급",
|
"label": "승인 등급",
|
||||||
@@ -543,13 +543,13 @@ func TestAcceptConsentRequest_IncludesRPProfileClaims(t *testing.T) {
|
|||||||
}
|
}
|
||||||
if r.URL.Path == "/oauth2/auth/requests/consent/accept" && r.URL.Query().Get("consent_challenge") == "challenge-rp-profile" {
|
if r.URL.Path == "/oauth2/auth/requests/consent/accept" && r.URL.Query().Get("consent_challenge") == "challenge-rp-profile" {
|
||||||
body, _ := io.ReadAll(r.Body)
|
body, _ := io.ReadAll(r.Body)
|
||||||
var acceptReq map[string]interface{}
|
var acceptReq map[string]any
|
||||||
json.Unmarshal(body, &acceptReq)
|
json.Unmarshal(body, &acceptReq)
|
||||||
if session, ok := acceptReq["session"].(map[string]interface{}); ok {
|
if session, ok := acceptReq["session"].(map[string]any); ok {
|
||||||
capturedClaims = session["id_token"].(map[string]interface{})
|
capturedClaims = session["id_token"].(map[string]any)
|
||||||
}
|
}
|
||||||
|
|
||||||
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
|
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||||
"redirect_to": "http://rp/cb",
|
"redirect_to": "http://rp/cb",
|
||||||
}), nil
|
}), nil
|
||||||
}
|
}
|
||||||
@@ -566,7 +566,7 @@ func TestAcceptConsentRequest_IncludesRPProfileClaims(t *testing.T) {
|
|||||||
}
|
}
|
||||||
h.KratosAdmin.(*MockKratosAdminService).On("GetIdentity", mock.Anything, "user-123").Return(&service.KratosIdentity{
|
h.KratosAdmin.(*MockKratosAdminService).On("GetIdentity", mock.Anything, "user-123").Return(&service.KratosIdentity{
|
||||||
ID: "user-123",
|
ID: "user-123",
|
||||||
Traits: map[string]interface{}{
|
Traits: map[string]any{
|
||||||
"email": "user@test.com",
|
"email": "user@test.com",
|
||||||
"name": "Test User",
|
"name": "Test User",
|
||||||
},
|
},
|
||||||
@@ -585,7 +585,7 @@ func TestAcceptConsentRequest_IncludesRPProfileClaims(t *testing.T) {
|
|||||||
app := fiber.New()
|
app := fiber.New()
|
||||||
app.Post("/api/v1/auth/consent/accept", h.AcceptConsentRequest)
|
app.Post("/api/v1/auth/consent/accept", h.AcceptConsentRequest)
|
||||||
|
|
||||||
reqBody, _ := json.Marshal(map[string]interface{}{
|
reqBody, _ := json.Marshal(map[string]any{
|
||||||
"consent_challenge": "challenge-rp-profile",
|
"consent_challenge": "challenge-rp-profile",
|
||||||
"grant_scope": []string{"openid", "profile"},
|
"grant_scope": []string{"openid", "profile"},
|
||||||
})
|
})
|
||||||
@@ -597,31 +597,31 @@ func TestAcceptConsentRequest_IncludesRPProfileClaims(t *testing.T) {
|
|||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
assert.NotNil(t, capturedClaims)
|
assert.NotNil(t, capturedClaims)
|
||||||
rpProfiles, ok := capturedClaims["rp_profiles"].([]interface{})
|
rpProfiles, ok := capturedClaims["rp_profiles"].([]any)
|
||||||
assert.True(t, ok)
|
assert.True(t, ok)
|
||||||
assert.Len(t, rpProfiles, 1)
|
assert.Len(t, rpProfiles, 1)
|
||||||
profile := rpProfiles[0].(map[string]interface{})
|
profile := rpProfiles[0].(map[string]any)
|
||||||
assert.Equal(t, "client-app", profile["client_id"])
|
assert.Equal(t, "client-app", profile["client_id"])
|
||||||
fields := profile["fields"].(map[string]interface{})
|
fields := profile["fields"].(map[string]any)
|
||||||
assert.Equal(t, "A", fields["approvalLevel"])
|
assert.Equal(t, "A", fields["approvalLevel"])
|
||||||
assert.NotContains(t, fields, "internalMemo")
|
assert.NotContains(t, fields, "internalMemo")
|
||||||
repo.AssertExpectations(t)
|
repo.AssertExpectations(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetConsentRequest_Skip_DynamicClaims(t *testing.T) {
|
func TestGetConsentRequest_Skip_DynamicClaims(t *testing.T) {
|
||||||
var capturedClaims map[string]interface{}
|
var capturedClaims map[string]any
|
||||||
|
|
||||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
// Hydra: Get Consent Request
|
// Hydra: Get Consent Request
|
||||||
if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-skip-dynamic" {
|
if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-skip-dynamic" {
|
||||||
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
|
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||||
"challenge": "challenge-skip-dynamic",
|
"challenge": "challenge-skip-dynamic",
|
||||||
"requested_scope": []string{"openid", "profile", "tenant"},
|
"requested_scope": []string{"openid", "profile", "tenant"},
|
||||||
"skip": true,
|
"skip": true,
|
||||||
"subject": "user-456",
|
"subject": "user-456",
|
||||||
"client": map[string]interface{}{
|
"client": map[string]any{
|
||||||
"client_id": "skip-app",
|
"client_id": "skip-app",
|
||||||
"metadata": map[string]interface{}{
|
"metadata": map[string]any{
|
||||||
"tenant_id": "tenant-xyz",
|
"tenant_id": "tenant-xyz",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -629,11 +629,11 @@ func TestGetConsentRequest_Skip_DynamicClaims(t *testing.T) {
|
|||||||
}
|
}
|
||||||
// Kratos: Get Identity
|
// Kratos: Get Identity
|
||||||
if r.URL.Path == "/admin/identities/user-456" {
|
if r.URL.Path == "/admin/identities/user-456" {
|
||||||
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
|
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||||
"id": "user-456",
|
"id": "user-456",
|
||||||
"traits": map[string]interface{}{
|
"traits": map[string]any{
|
||||||
"email": "skip@test.com",
|
"email": "skip@test.com",
|
||||||
"tenant-xyz": map[string]interface{}{
|
"tenant-xyz": map[string]any{
|
||||||
"department": "Security",
|
"department": "Security",
|
||||||
"position": "Officer",
|
"position": "Officer",
|
||||||
},
|
},
|
||||||
@@ -644,13 +644,13 @@ func TestGetConsentRequest_Skip_DynamicClaims(t *testing.T) {
|
|||||||
if r.URL.Path == "/oauth2/auth/requests/consent/accept" && r.URL.Query().Get("consent_challenge") == "challenge-skip-dynamic" {
|
if r.URL.Path == "/oauth2/auth/requests/consent/accept" && r.URL.Query().Get("consent_challenge") == "challenge-skip-dynamic" {
|
||||||
// Capture the claims sent to Hydra
|
// Capture the claims sent to Hydra
|
||||||
body, _ := io.ReadAll(r.Body)
|
body, _ := io.ReadAll(r.Body)
|
||||||
var acceptReq map[string]interface{}
|
var acceptReq map[string]any
|
||||||
json.Unmarshal(body, &acceptReq)
|
json.Unmarshal(body, &acceptReq)
|
||||||
if session, ok := acceptReq["session"].(map[string]interface{}); ok {
|
if session, ok := acceptReq["session"].(map[string]any); ok {
|
||||||
capturedClaims = session["id_token"].(map[string]interface{})
|
capturedClaims = session["id_token"].(map[string]any)
|
||||||
}
|
}
|
||||||
|
|
||||||
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
|
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||||
"redirect_to": "http://rp/cb",
|
"redirect_to": "http://rp/cb",
|
||||||
}), nil
|
}), nil
|
||||||
}
|
}
|
||||||
@@ -671,9 +671,9 @@ func TestGetConsentRequest_Skip_DynamicClaims(t *testing.T) {
|
|||||||
}
|
}
|
||||||
h.KratosAdmin.(*MockKratosAdminService).On("GetIdentity", mock.Anything, "user-456").Return(&service.KratosIdentity{
|
h.KratosAdmin.(*MockKratosAdminService).On("GetIdentity", mock.Anything, "user-456").Return(&service.KratosIdentity{
|
||||||
ID: "user-456",
|
ID: "user-456",
|
||||||
Traits: map[string]interface{}{
|
Traits: map[string]any{
|
||||||
"email": "skip@test.com",
|
"email": "skip@test.com",
|
||||||
"tenant-xyz": map[string]interface{}{
|
"tenant-xyz": map[string]any{
|
||||||
"department": "Security",
|
"department": "Security",
|
||||||
"position": "Officer",
|
"position": "Officer",
|
||||||
},
|
},
|
||||||
@@ -697,19 +697,19 @@ func TestGetConsentRequest_Skip_DynamicClaims(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAcceptConsentRequest_AppliesConfiguredIDTokenClaims(t *testing.T) {
|
func TestAcceptConsentRequest_AppliesConfiguredIDTokenClaims(t *testing.T) {
|
||||||
var capturedClaims map[string]interface{}
|
var capturedClaims map[string]any
|
||||||
|
|
||||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-configured-claims" {
|
if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-configured-claims" {
|
||||||
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
|
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||||
"challenge": "challenge-configured-claims",
|
"challenge": "challenge-configured-claims",
|
||||||
"requested_scope": []string{"openid", "profile"},
|
"requested_scope": []string{"openid", "profile"},
|
||||||
"subject": "user-789",
|
"subject": "user-789",
|
||||||
"client": map[string]interface{}{
|
"client": map[string]any{
|
||||||
"client_id": "client-configured-claims",
|
"client_id": "client-configured-claims",
|
||||||
"metadata": map[string]interface{}{
|
"metadata": map[string]any{
|
||||||
"tenant_id": "tenant-claims",
|
"tenant_id": "tenant-claims",
|
||||||
"id_token_claims": []map[string]interface{}{
|
"id_token_claims": []map[string]any{
|
||||||
{
|
{
|
||||||
"namespace": "top_level",
|
"namespace": "top_level",
|
||||||
"key": "locale",
|
"key": "locale",
|
||||||
@@ -741,13 +741,13 @@ func TestAcceptConsentRequest_AppliesConfiguredIDTokenClaims(t *testing.T) {
|
|||||||
}
|
}
|
||||||
if r.URL.Path == "/oauth2/auth/requests/consent/accept" && r.URL.Query().Get("consent_challenge") == "challenge-configured-claims" {
|
if r.URL.Path == "/oauth2/auth/requests/consent/accept" && r.URL.Query().Get("consent_challenge") == "challenge-configured-claims" {
|
||||||
body, _ := io.ReadAll(r.Body)
|
body, _ := io.ReadAll(r.Body)
|
||||||
var acceptReq map[string]interface{}
|
var acceptReq map[string]any
|
||||||
json.Unmarshal(body, &acceptReq)
|
json.Unmarshal(body, &acceptReq)
|
||||||
if session, ok := acceptReq["session"].(map[string]interface{}); ok {
|
if session, ok := acceptReq["session"].(map[string]any); ok {
|
||||||
capturedClaims = session["id_token"].(map[string]interface{})
|
capturedClaims = session["id_token"].(map[string]any)
|
||||||
}
|
}
|
||||||
|
|
||||||
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
|
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||||
"redirect_to": "http://rp/cb",
|
"redirect_to": "http://rp/cb",
|
||||||
}), nil
|
}), nil
|
||||||
}
|
}
|
||||||
@@ -768,10 +768,10 @@ func TestAcceptConsentRequest_AppliesConfiguredIDTokenClaims(t *testing.T) {
|
|||||||
}
|
}
|
||||||
h.KratosAdmin.(*MockKratosAdminService).On("GetIdentity", mock.Anything, "user-789").Return(&service.KratosIdentity{
|
h.KratosAdmin.(*MockKratosAdminService).On("GetIdentity", mock.Anything, "user-789").Return(&service.KratosIdentity{
|
||||||
ID: "user-789",
|
ID: "user-789",
|
||||||
Traits: map[string]interface{}{
|
Traits: map[string]any{
|
||||||
"email": "real-user@example.com",
|
"email": "real-user@example.com",
|
||||||
"name": "Configured User",
|
"name": "Configured User",
|
||||||
"tenant-claims": map[string]interface{}{
|
"tenant-claims": map[string]any{
|
||||||
"department": "Platform",
|
"department": "Platform",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -780,7 +780,7 @@ func TestAcceptConsentRequest_AppliesConfiguredIDTokenClaims(t *testing.T) {
|
|||||||
app := fiber.New()
|
app := fiber.New()
|
||||||
app.Post("/api/v1/auth/consent/accept", h.AcceptConsentRequest)
|
app.Post("/api/v1/auth/consent/accept", h.AcceptConsentRequest)
|
||||||
|
|
||||||
reqBody, _ := json.Marshal(map[string]interface{}{
|
reqBody, _ := json.Marshal(map[string]any{
|
||||||
"consent_challenge": "challenge-configured-claims",
|
"consent_challenge": "challenge-configured-claims",
|
||||||
"grant_scope": []string{"openid", "profile"},
|
"grant_scope": []string{"openid", "profile"},
|
||||||
})
|
})
|
||||||
@@ -796,9 +796,9 @@ func TestAcceptConsentRequest_AppliesConfiguredIDTokenClaims(t *testing.T) {
|
|||||||
assert.Equal(t, "ko-KR", capturedClaims["locale"])
|
assert.Equal(t, "ko-KR", capturedClaims["locale"])
|
||||||
assert.Equal(t, "tenant-claims", capturedClaims["tenant_id"])
|
assert.Equal(t, "tenant-claims", capturedClaims["tenant_id"])
|
||||||
|
|
||||||
rpClaims, ok := capturedClaims["rp_claims"].(map[string]interface{})
|
rpClaims, ok := capturedClaims["rp_claims"].(map[string]any)
|
||||||
if assert.True(t, ok) {
|
if assert.True(t, ok) {
|
||||||
assert.Equal(t, float64(2), rpClaims["tier"])
|
assert.Equal(t, float64(2), rpClaims["tier"])
|
||||||
assert.Equal(t, []interface{}{"sso", "claims"}, rpClaims["features"])
|
assert.Equal(t, []any{"sso", "claims"}, rpClaims["features"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,12 +61,12 @@ func newKratosWhoamiTestServer(t *testing.T, identityID string) *httptest.Server
|
|||||||
http.Error(w, "missing session", http.StatusUnauthorized)
|
http.Error(w, "missing session", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
"id": "session-123",
|
"id": "session-123",
|
||||||
"authenticated_at": "2026-05-21T00:00:00Z",
|
"authenticated_at": "2026-05-21T00:00:00Z",
|
||||||
"identity": map[string]interface{}{
|
"identity": map[string]any{
|
||||||
"id": identityID,
|
"id": identityID,
|
||||||
"traits": map[string]interface{}{
|
"traits": map[string]any{
|
||||||
"email": "user@example.com",
|
"email": "user@example.com",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -113,7 +113,7 @@ func TestEnchantedLinkFlow_Email_Success(t *testing.T) {
|
|||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var initResp map[string]interface{}
|
var initResp map[string]any
|
||||||
json.NewDecoder(resp.Body).Decode(&initResp)
|
json.NewDecoder(resp.Body).Decode(&initResp)
|
||||||
pendingRef := initResp["pendingRef"].(string)
|
pendingRef := initResp["pendingRef"].(string)
|
||||||
assert.NotEmpty(t, pendingRef)
|
assert.NotEmpty(t, pendingRef)
|
||||||
@@ -129,7 +129,7 @@ func TestEnchantedLinkFlow_Email_Success(t *testing.T) {
|
|||||||
assert.NotEmpty(t, token)
|
assert.NotEmpty(t, token)
|
||||||
|
|
||||||
// 2. Verify Magic Link
|
// 2. Verify Magic Link
|
||||||
verifyBody, _ := json.Marshal(map[string]interface{}{
|
verifyBody, _ := json.Marshal(map[string]any{
|
||||||
"token": token,
|
"token": token,
|
||||||
"verifyOnly": true,
|
"verifyOnly": true,
|
||||||
})
|
})
|
||||||
@@ -145,7 +145,7 @@ func TestEnchantedLinkFlow_Email_Success(t *testing.T) {
|
|||||||
resp, _ = app.Test(req, -1)
|
resp, _ = app.Test(req, -1)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
var pollResp map[string]interface{}
|
var pollResp map[string]any
|
||||||
json.NewDecoder(resp.Body).Decode(&pollResp)
|
json.NewDecoder(resp.Body).Decode(&pollResp)
|
||||||
assert.Equal(t, "ok", pollResp["status"])
|
assert.Equal(t, "ok", pollResp["status"])
|
||||||
assert.Equal(t, "valid-jwt", pollResp["sessionJwt"])
|
assert.Equal(t, "valid-jwt", pollResp["sessionJwt"])
|
||||||
@@ -177,7 +177,7 @@ func TestEnchantedLinkFlow_Sms_Success(t *testing.T) {
|
|||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var initResp map[string]interface{}
|
var initResp map[string]any
|
||||||
json.NewDecoder(resp.Body).Decode(&initResp)
|
json.NewDecoder(resp.Body).Decode(&initResp)
|
||||||
assert.NotEmpty(t, initResp["userCode"])
|
assert.NotEmpty(t, initResp["userCode"])
|
||||||
}
|
}
|
||||||
@@ -193,7 +193,7 @@ func TestVerifyMagicLink_VerifyOnlyWithoutSharedBrowserSessionApprovesOnly(t *te
|
|||||||
app := fiber.New()
|
app := fiber.New()
|
||||||
app.Post("/api/v1/auth/magic-link/verify", h.VerifyMagicLink)
|
app.Post("/api/v1/auth/magic-link/verify", h.VerifyMagicLink)
|
||||||
|
|
||||||
body, _ := json.Marshal(map[string]interface{}{
|
body, _ := json.Marshal(map[string]any{
|
||||||
"token": "token-123",
|
"token": "token-123",
|
||||||
"verifyOnly": true,
|
"verifyOnly": true,
|
||||||
})
|
})
|
||||||
@@ -204,7 +204,7 @@ func TestVerifyMagicLink_VerifyOnlyWithoutSharedBrowserSessionApprovesOnly(t *te
|
|||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
assert.Empty(t, resp.Cookies())
|
assert.Empty(t, resp.Cookies())
|
||||||
|
|
||||||
var got map[string]interface{}
|
var got map[string]any
|
||||||
_ = json.NewDecoder(resp.Body).Decode(&got)
|
_ = json.NewDecoder(resp.Body).Decode(&got)
|
||||||
assert.Equal(t, "approved", got["status"])
|
assert.Equal(t, "approved", got["status"])
|
||||||
assert.Nil(t, got["sessionJwt"])
|
assert.Nil(t, got["sessionJwt"])
|
||||||
@@ -225,7 +225,7 @@ func TestVerifyMagicLink_VerifyOnlySharedBrowserSameSubjectApprovesOnly(t *testi
|
|||||||
app := fiber.New()
|
app := fiber.New()
|
||||||
app.Post("/api/v1/auth/magic-link/verify", h.VerifyMagicLink)
|
app.Post("/api/v1/auth/magic-link/verify", h.VerifyMagicLink)
|
||||||
|
|
||||||
body, _ := json.Marshal(map[string]interface{}{
|
body, _ := json.Marshal(map[string]any{
|
||||||
"token": "token-123",
|
"token": "token-123",
|
||||||
"verifyOnly": true,
|
"verifyOnly": true,
|
||||||
})
|
})
|
||||||
@@ -237,7 +237,7 @@ func TestVerifyMagicLink_VerifyOnlySharedBrowserSameSubjectApprovesOnly(t *testi
|
|||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
assert.Empty(t, resp.Cookies())
|
assert.Empty(t, resp.Cookies())
|
||||||
|
|
||||||
var got map[string]interface{}
|
var got map[string]any
|
||||||
_ = json.NewDecoder(resp.Body).Decode(&got)
|
_ = json.NewDecoder(resp.Body).Decode(&got)
|
||||||
assert.Equal(t, "approved", got["status"])
|
assert.Equal(t, "approved", got["status"])
|
||||||
assert.Nil(t, got["sessionJwt"])
|
assert.Nil(t, got["sessionJwt"])
|
||||||
@@ -258,7 +258,7 @@ func TestVerifyMagicLink_VerifyOnlySharedBrowserDifferentSubjectApprovesOnly(t *
|
|||||||
app := fiber.New()
|
app := fiber.New()
|
||||||
app.Post("/api/v1/auth/magic-link/verify", h.VerifyMagicLink)
|
app.Post("/api/v1/auth/magic-link/verify", h.VerifyMagicLink)
|
||||||
|
|
||||||
body, _ := json.Marshal(map[string]interface{}{
|
body, _ := json.Marshal(map[string]any{
|
||||||
"token": "token-123",
|
"token": "token-123",
|
||||||
"verifyOnly": true,
|
"verifyOnly": true,
|
||||||
})
|
})
|
||||||
@@ -270,7 +270,7 @@ func TestVerifyMagicLink_VerifyOnlySharedBrowserDifferentSubjectApprovesOnly(t *
|
|||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
assert.Empty(t, resp.Cookies())
|
assert.Empty(t, resp.Cookies())
|
||||||
|
|
||||||
var got map[string]interface{}
|
var got map[string]any
|
||||||
_ = json.NewDecoder(resp.Body).Decode(&got)
|
_ = json.NewDecoder(resp.Body).Decode(&got)
|
||||||
assert.Equal(t, "approved", got["status"])
|
assert.Equal(t, "approved", got["status"])
|
||||||
assert.Nil(t, got["sessionJwt"])
|
assert.Nil(t, got["sessionJwt"])
|
||||||
@@ -312,7 +312,7 @@ func TestVerifyLoginCode_VerifyOnlySharedBrowserDifferentSubjectApprovesOnly(t *
|
|||||||
app := fiber.New()
|
app := fiber.New()
|
||||||
app.Post("/api/v1/auth/login/code/verify", h.VerifyLoginCode)
|
app.Post("/api/v1/auth/login/code/verify", h.VerifyLoginCode)
|
||||||
|
|
||||||
body, _ := json.Marshal(map[string]interface{}{
|
body, _ := json.Marshal(map[string]any{
|
||||||
"loginId": "user@example.com",
|
"loginId": "user@example.com",
|
||||||
"code": "569765",
|
"code": "569765",
|
||||||
"pendingRef": "pending-123",
|
"pendingRef": "pending-123",
|
||||||
@@ -326,7 +326,7 @@ func TestVerifyLoginCode_VerifyOnlySharedBrowserDifferentSubjectApprovesOnly(t *
|
|||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
assert.Empty(t, resp.Cookies())
|
assert.Empty(t, resp.Cookies())
|
||||||
|
|
||||||
var got map[string]interface{}
|
var got map[string]any
|
||||||
_ = json.NewDecoder(resp.Body).Decode(&got)
|
_ = json.NewDecoder(resp.Body).Decode(&got)
|
||||||
assert.Equal(t, "approved", got["status"])
|
assert.Equal(t, "approved", got["status"])
|
||||||
assert.Nil(t, got["sessionJwt"])
|
assert.Nil(t, got["sessionJwt"])
|
||||||
@@ -349,7 +349,7 @@ func TestVerifyLoginCode_MapsSmsPhoneBeforeFlowLookup(t *testing.T) {
|
|||||||
app := fiber.New()
|
app := fiber.New()
|
||||||
app.Post("/api/v1/auth/login/code/verify", h.VerifyLoginCode)
|
app.Post("/api/v1/auth/login/code/verify", h.VerifyLoginCode)
|
||||||
|
|
||||||
body, _ := json.Marshal(map[string]interface{}{
|
body, _ := json.Marshal(map[string]any{
|
||||||
"loginId": "01041585840",
|
"loginId": "01041585840",
|
||||||
"code": "569765",
|
"code": "569765",
|
||||||
"pendingRef": "pending-123",
|
"pendingRef": "pending-123",
|
||||||
@@ -360,7 +360,7 @@ func TestVerifyLoginCode_MapsSmsPhoneBeforeFlowLookup(t *testing.T) {
|
|||||||
resp, _ := app.Test(req, -1)
|
resp, _ := app.Test(req, -1)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
var got map[string]interface{}
|
var got map[string]any
|
||||||
_ = json.NewDecoder(resp.Body).Decode(&got)
|
_ = json.NewDecoder(resp.Body).Decode(&got)
|
||||||
assert.Equal(t, "approved", got["status"])
|
assert.Equal(t, "approved", got["status"])
|
||||||
assert.Equal(t, "pending-123", got["pendingRef"])
|
assert.Equal(t, "pending-123", got["pendingRef"])
|
||||||
@@ -383,7 +383,7 @@ func TestPollEnchantedLink_ExpiredToken_ReturnsCode(t *testing.T) {
|
|||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var got map[string]interface{}
|
var got map[string]any
|
||||||
json.NewDecoder(resp.Body).Decode(&got)
|
json.NewDecoder(resp.Body).Decode(&got)
|
||||||
assert.Equal(t, "expired_token", got["error"])
|
assert.Equal(t, "expired_token", got["error"])
|
||||||
assert.Equal(t, "expired_token", got["code"])
|
assert.Equal(t, "expired_token", got["code"])
|
||||||
@@ -415,7 +415,7 @@ func TestPollEnchantedLink_SharedBrowserSameSubjectIssuesSession(t *testing.T) {
|
|||||||
resp, _ := app.Test(req, -1)
|
resp, _ := app.Test(req, -1)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
var got map[string]interface{}
|
var got map[string]any
|
||||||
_ = json.NewDecoder(resp.Body).Decode(&got)
|
_ = json.NewDecoder(resp.Body).Decode(&got)
|
||||||
assert.Equal(t, "ok", got["status"])
|
assert.Equal(t, "ok", got["status"])
|
||||||
assert.Equal(t, "valid-jwt", got["sessionJwt"])
|
assert.Equal(t, "valid-jwt", got["sessionJwt"])
|
||||||
@@ -447,7 +447,7 @@ func TestPollEnchantedLink_SharedBrowserDifferentSubjectConflicts(t *testing.T)
|
|||||||
resp, _ := app.Test(req, -1)
|
resp, _ := app.Test(req, -1)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusConflict, resp.StatusCode)
|
assert.Equal(t, http.StatusConflict, resp.StatusCode)
|
||||||
var got map[string]interface{}
|
var got map[string]any
|
||||||
_ = json.NewDecoder(resp.Body).Decode(&got)
|
_ = json.NewDecoder(resp.Body).Decode(&got)
|
||||||
assert.Equal(t, "session_subject_conflict", got["code"])
|
assert.Equal(t, "session_subject_conflict", got["code"])
|
||||||
assert.NotContains(t, redis.data[prefixSession+"pending-123"], "valid-jwt")
|
assert.NotContains(t, redis.data[prefixSession+"pending-123"], "valid-jwt")
|
||||||
@@ -481,7 +481,7 @@ func TestHeadlessLinkInit_HeadlessLoginClientSuccess(t *testing.T) {
|
|||||||
Client: domain.HydraClient{
|
Client: domain.HydraClient{
|
||||||
ClientID: "headless-login-client",
|
ClientID: "headless-login-client",
|
||||||
TokenEndpointAuthMethod: "none",
|
TokenEndpointAuthMethod: "none",
|
||||||
Metadata: map[string]interface{}{
|
Metadata: map[string]any{
|
||||||
"status": "active",
|
"status": "active",
|
||||||
"headless_login_enabled": true,
|
"headless_login_enabled": true,
|
||||||
"headless_token_endpoint_auth_method": "private_key_jwt",
|
"headless_token_endpoint_auth_method": "private_key_jwt",
|
||||||
@@ -519,7 +519,7 @@ func TestHeadlessLinkInit_HeadlessLoginClientSuccess(t *testing.T) {
|
|||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var got map[string]interface{}
|
var got map[string]any
|
||||||
_ = json.NewDecoder(resp.Body).Decode(&got)
|
_ = json.NewDecoder(resp.Body).Decode(&got)
|
||||||
assert.NotEmpty(t, got["pendingRef"])
|
assert.NotEmpty(t, got["pendingRef"])
|
||||||
_, hasUserCode := got["userCode"]
|
_, hasUserCode := got["userCode"]
|
||||||
@@ -551,7 +551,7 @@ func TestHeadlessLinkPoll_AfterApprovalReturnsRedirect(t *testing.T) {
|
|||||||
ClientID: "headless-login-client",
|
ClientID: "headless-login-client",
|
||||||
ClientName: "local-demo-rp",
|
ClientName: "local-demo-rp",
|
||||||
TokenEndpointAuthMethod: "none",
|
TokenEndpointAuthMethod: "none",
|
||||||
Metadata: map[string]interface{}{
|
Metadata: map[string]any{
|
||||||
"status": "active",
|
"status": "active",
|
||||||
"headless_login_enabled": true,
|
"headless_login_enabled": true,
|
||||||
"headless_token_endpoint_auth_method": "private_key_jwt",
|
"headless_token_endpoint_auth_method": "private_key_jwt",
|
||||||
@@ -604,7 +604,7 @@ func TestHeadlessLinkPoll_AfterApprovalReturnsRedirect(t *testing.T) {
|
|||||||
resp, _ := app.Test(req, -1)
|
resp, _ := app.Test(req, -1)
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var initResp map[string]interface{}
|
var initResp map[string]any
|
||||||
_ = json.NewDecoder(resp.Body).Decode(&initResp)
|
_ = json.NewDecoder(resp.Body).Decode(&initResp)
|
||||||
pendingRef := initResp["pendingRef"].(string)
|
pendingRef := initResp["pendingRef"].(string)
|
||||||
assert.NotEmpty(t, pendingRef)
|
assert.NotEmpty(t, pendingRef)
|
||||||
@@ -618,7 +618,7 @@ func TestHeadlessLinkPoll_AfterApprovalReturnsRedirect(t *testing.T) {
|
|||||||
}
|
}
|
||||||
assert.NotEmpty(t, token)
|
assert.NotEmpty(t, token)
|
||||||
|
|
||||||
verifyBody, _ := json.Marshal(map[string]interface{}{
|
verifyBody, _ := json.Marshal(map[string]any{
|
||||||
"token": token,
|
"token": token,
|
||||||
"verifyOnly": true,
|
"verifyOnly": true,
|
||||||
})
|
})
|
||||||
@@ -637,7 +637,7 @@ func TestHeadlessLinkPoll_AfterApprovalReturnsRedirect(t *testing.T) {
|
|||||||
resp, _ = app.Test(req, -1)
|
resp, _ = app.Test(req, -1)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
var pollResp map[string]interface{}
|
var pollResp map[string]any
|
||||||
_ = json.NewDecoder(resp.Body).Decode(&pollResp)
|
_ = json.NewDecoder(resp.Body).Decode(&pollResp)
|
||||||
assert.Equal(t, "http://rp/cb", pollResp["redirectTo"])
|
assert.Equal(t, "http://rp/cb", pollResp["redirectTo"])
|
||||||
assert.Equal(t, "ok", pollResp["status"])
|
assert.Equal(t, "ok", pollResp["status"])
|
||||||
@@ -677,7 +677,7 @@ func TestHeadlessLinkPoll_ApproverSubjectConflictBlocksMixedRP(t *testing.T) {
|
|||||||
ClientID: "headless-login-client",
|
ClientID: "headless-login-client",
|
||||||
ClientName: "local-demo-rp",
|
ClientName: "local-demo-rp",
|
||||||
TokenEndpointAuthMethod: "none",
|
TokenEndpointAuthMethod: "none",
|
||||||
Metadata: map[string]interface{}{
|
Metadata: map[string]any{
|
||||||
"status": "active",
|
"status": "active",
|
||||||
"headless_login_enabled": true,
|
"headless_login_enabled": true,
|
||||||
"headless_token_endpoint_auth_method": "private_key_jwt",
|
"headless_token_endpoint_auth_method": "private_key_jwt",
|
||||||
@@ -734,7 +734,7 @@ func TestHeadlessLinkPoll_ApproverSubjectConflictBlocksMixedRP(t *testing.T) {
|
|||||||
resp, _ := app.Test(req, -1)
|
resp, _ := app.Test(req, -1)
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var initResp map[string]interface{}
|
var initResp map[string]any
|
||||||
_ = json.NewDecoder(resp.Body).Decode(&initResp)
|
_ = json.NewDecoder(resp.Body).Decode(&initResp)
|
||||||
pendingRef := initResp["pendingRef"].(string)
|
pendingRef := initResp["pendingRef"].(string)
|
||||||
assert.NotEmpty(t, pendingRef)
|
assert.NotEmpty(t, pendingRef)
|
||||||
@@ -751,7 +751,7 @@ func TestHeadlessLinkPoll_ApproverSubjectConflictBlocksMixedRP(t *testing.T) {
|
|||||||
kratosPublic := newKratosWhoamiTestServer(t, "kratos-userfront-a")
|
kratosPublic := newKratosWhoamiTestServer(t, "kratos-userfront-a")
|
||||||
t.Setenv("KRATOS_PUBLIC_URL", kratosPublic.URL)
|
t.Setenv("KRATOS_PUBLIC_URL", kratosPublic.URL)
|
||||||
|
|
||||||
verifyBody, _ := json.Marshal(map[string]interface{}{
|
verifyBody, _ := json.Marshal(map[string]any{
|
||||||
"token": token,
|
"token": token,
|
||||||
"verifyOnly": true,
|
"verifyOnly": true,
|
||||||
})
|
})
|
||||||
@@ -773,7 +773,7 @@ func TestHeadlessLinkPoll_ApproverSubjectConflictBlocksMixedRP(t *testing.T) {
|
|||||||
assert.Equal(t, http.StatusConflict, resp.StatusCode)
|
assert.Equal(t, http.StatusConflict, resp.StatusCode)
|
||||||
assert.False(t, acceptCalled)
|
assert.False(t, acceptCalled)
|
||||||
assert.Empty(t, resp.Cookies())
|
assert.Empty(t, resp.Cookies())
|
||||||
var got map[string]interface{}
|
var got map[string]any
|
||||||
_ = json.NewDecoder(resp.Body).Decode(&got)
|
_ = json.NewDecoder(resp.Body).Decode(&got)
|
||||||
assert.Equal(t, "oidc_subject_conflict", got["code"])
|
assert.Equal(t, "oidc_subject_conflict", got["code"])
|
||||||
assert.Equal(t, "redirect_to_userfront_login", got["recommendedAction"])
|
assert.Equal(t, "redirect_to_userfront_login", got["recommendedAction"])
|
||||||
@@ -802,7 +802,7 @@ func TestHeadlessLinkPoll_RequestCookieSubjectConflictBlocksMixedRP(t *testing.T
|
|||||||
Client: domain.HydraClient{
|
Client: domain.HydraClient{
|
||||||
ClientID: "headless-login-client",
|
ClientID: "headless-login-client",
|
||||||
TokenEndpointAuthMethod: "none",
|
TokenEndpointAuthMethod: "none",
|
||||||
Metadata: map[string]interface{}{
|
Metadata: map[string]any{
|
||||||
"status": "active",
|
"status": "active",
|
||||||
"headless_login_enabled": true,
|
"headless_login_enabled": true,
|
||||||
"headless_token_endpoint_auth_method": "private_key_jwt",
|
"headless_token_endpoint_auth_method": "private_key_jwt",
|
||||||
@@ -857,7 +857,7 @@ func TestHeadlessLinkPoll_RequestCookieSubjectConflictBlocksMixedRP(t *testing.T
|
|||||||
resp, _ := app.Test(req, -1)
|
resp, _ := app.Test(req, -1)
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var initResp map[string]interface{}
|
var initResp map[string]any
|
||||||
_ = json.NewDecoder(resp.Body).Decode(&initResp)
|
_ = json.NewDecoder(resp.Body).Decode(&initResp)
|
||||||
pendingRef := initResp["pendingRef"].(string)
|
pendingRef := initResp["pendingRef"].(string)
|
||||||
assert.NotEmpty(t, pendingRef)
|
assert.NotEmpty(t, pendingRef)
|
||||||
@@ -871,7 +871,7 @@ func TestHeadlessLinkPoll_RequestCookieSubjectConflictBlocksMixedRP(t *testing.T
|
|||||||
}
|
}
|
||||||
assert.NotEmpty(t, token)
|
assert.NotEmpty(t, token)
|
||||||
|
|
||||||
verifyBody, _ := json.Marshal(map[string]interface{}{
|
verifyBody, _ := json.Marshal(map[string]any{
|
||||||
"token": token,
|
"token": token,
|
||||||
"verifyOnly": true,
|
"verifyOnly": true,
|
||||||
})
|
})
|
||||||
@@ -896,7 +896,7 @@ func TestHeadlessLinkPoll_RequestCookieSubjectConflictBlocksMixedRP(t *testing.T
|
|||||||
assert.Equal(t, http.StatusConflict, resp.StatusCode)
|
assert.Equal(t, http.StatusConflict, resp.StatusCode)
|
||||||
assert.False(t, acceptCalled)
|
assert.False(t, acceptCalled)
|
||||||
assert.Empty(t, resp.Cookies())
|
assert.Empty(t, resp.Cookies())
|
||||||
var got map[string]interface{}
|
var got map[string]any
|
||||||
_ = json.NewDecoder(resp.Body).Decode(&got)
|
_ = json.NewDecoder(resp.Body).Decode(&got)
|
||||||
assert.Equal(t, "oidc_subject_conflict", got["code"])
|
assert.Equal(t, "oidc_subject_conflict", got["code"])
|
||||||
assert.Equal(t, "kratos-userfront-a", got["currentSubject"])
|
assert.Equal(t, "kratos-userfront-a", got["currentSubject"])
|
||||||
|
|||||||
@@ -32,10 +32,10 @@ func TestListLinkedRps_PriorityAndAggregation(t *testing.T) {
|
|||||||
if r.Header.Get("X-Session-Token") == "" && r.Header.Get("Cookie") == "" {
|
if r.Header.Get("X-Session-Token") == "" && r.Header.Get("Cookie") == "" {
|
||||||
return httpResponse(r, http.StatusUnauthorized, "unauthorized"), nil
|
return httpResponse(r, http.StatusUnauthorized, "unauthorized"), nil
|
||||||
}
|
}
|
||||||
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
|
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||||
"identity": map[string]interface{}{
|
"identity": map[string]any{
|
||||||
"id": "user-123",
|
"id": "user-123",
|
||||||
"traits": map[string]interface{}{
|
"traits": map[string]any{
|
||||||
"email": "user@test.com",
|
"email": "user@test.com",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -43,9 +43,9 @@ func TestListLinkedRps_PriorityAndAggregation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
case "hydra.test":
|
case "hydra.test":
|
||||||
if r.URL.Path == "/oauth2/auth/sessions/consent" {
|
if r.URL.Path == "/oauth2/auth/sessions/consent" {
|
||||||
return httpJSONAny(r, http.StatusOK, []map[string]interface{}{
|
return httpJSONAny(r, http.StatusOK, []map[string]any{
|
||||||
{
|
{
|
||||||
"client": map[string]interface{}{
|
"client": map[string]any{
|
||||||
"client_id": "devfront",
|
"client_id": "devfront",
|
||||||
"client_name": "DevFront",
|
"client_name": "DevFront",
|
||||||
"redirect_uris": []string{
|
"redirect_uris": []string{
|
||||||
@@ -56,10 +56,10 @@ func TestListLinkedRps_PriorityAndAggregation(t *testing.T) {
|
|||||||
"handled_at": time.Now().Format(time.RFC3339),
|
"handled_at": time.Now().Format(time.RFC3339),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"client": map[string]interface{}{
|
"client": map[string]any{
|
||||||
"client_id": "orgfront",
|
"client_id": "orgfront",
|
||||||
"client_name": "OrgFront",
|
"client_name": "OrgFront",
|
||||||
"metadata": map[string]interface{}{
|
"metadata": map[string]any{
|
||||||
"auto_login_supported": true,
|
"auto_login_supported": true,
|
||||||
"auto_login_url": "http://localhost:5175/login",
|
"auto_login_url": "http://localhost:5175/login",
|
||||||
},
|
},
|
||||||
@@ -73,13 +73,13 @@ func TestListLinkedRps_PriorityAndAggregation(t *testing.T) {
|
|||||||
}), nil
|
}), nil
|
||||||
}
|
}
|
||||||
if r.URL.Path == "/admin/clients/client-audit" {
|
if r.URL.Path == "/admin/clients/client-audit" {
|
||||||
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
|
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||||
"client_id": "client-audit",
|
"client_id": "client-audit",
|
||||||
"client_name": "Audit App",
|
"client_name": "Audit App",
|
||||||
}), nil
|
}), nil
|
||||||
}
|
}
|
||||||
if r.URL.Path == "/admin/clients/client-consent" {
|
if r.URL.Path == "/admin/clients/client-consent" {
|
||||||
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
|
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||||
"client_id": "client-consent",
|
"client_id": "client-consent",
|
||||||
"client_name": "Consent App",
|
"client_name": "Consent App",
|
||||||
}), nil
|
}), nil
|
||||||
@@ -206,17 +206,17 @@ func TestListLinkedRps_EnrichesLogoFromHydraClientWhenConsentSessionOmitsMetadat
|
|||||||
switch r.URL.Host {
|
switch r.URL.Host {
|
||||||
case "kratos.test":
|
case "kratos.test":
|
||||||
if r.URL.Path == "/sessions/whoami" {
|
if r.URL.Path == "/sessions/whoami" {
|
||||||
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
|
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||||
"identity": map[string]interface{}{
|
"identity": map[string]any{
|
||||||
"id": "user-123",
|
"id": "user-123",
|
||||||
},
|
},
|
||||||
}), nil
|
}), nil
|
||||||
}
|
}
|
||||||
case "hydra.test":
|
case "hydra.test":
|
||||||
if r.URL.Path == "/oauth2/auth/sessions/consent" {
|
if r.URL.Path == "/oauth2/auth/sessions/consent" {
|
||||||
return httpJSONAny(r, http.StatusOK, []map[string]interface{}{
|
return httpJSONAny(r, http.StatusOK, []map[string]any{
|
||||||
{
|
{
|
||||||
"client": map[string]interface{}{
|
"client": map[string]any{
|
||||||
"client_id": "gitea-client",
|
"client_id": "gitea-client",
|
||||||
"client_name": "Gitea",
|
"client_name": "Gitea",
|
||||||
"redirect_uris": []string{
|
"redirect_uris": []string{
|
||||||
@@ -229,13 +229,13 @@ func TestListLinkedRps_EnrichesLogoFromHydraClientWhenConsentSessionOmitsMetadat
|
|||||||
}), nil
|
}), nil
|
||||||
}
|
}
|
||||||
if r.URL.Path == "/clients/gitea-client" {
|
if r.URL.Path == "/clients/gitea-client" {
|
||||||
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
|
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||||
"client_id": "gitea-client",
|
"client_id": "gitea-client",
|
||||||
"client_name": "Gitea",
|
"client_name": "Gitea",
|
||||||
"redirect_uris": []string{
|
"redirect_uris": []string{
|
||||||
"https://gitea.example.com/callback",
|
"https://gitea.example.com/callback",
|
||||||
},
|
},
|
||||||
"metadata": map[string]interface{}{
|
"metadata": map[string]any{
|
||||||
"logo_url": "https://cdn.example.com/gitea.svg",
|
"logo_url": "https://cdn.example.com/gitea.svg",
|
||||||
},
|
},
|
||||||
}), nil
|
}), nil
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import (
|
|||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
"github.com/stretchr/testify/mock"
|
"github.com/stretchr/testify/mock"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// --- Mocks ---
|
// --- Mocks ---
|
||||||
@@ -113,7 +114,7 @@ func (m *MockKratosAdminService) ListIdentities(ctx context.Context) ([]service.
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockKratosAdminService) UpdateIdentity(ctx context.Context, identityID string, traits map[string]interface{}, state string) (*service.KratosIdentity, error) {
|
func (m *MockKratosAdminService) UpdateIdentity(ctx context.Context, identityID string, traits map[string]any, state string) (*service.KratosIdentity, error) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,6 +215,8 @@ func (r *passwordLoginUserRepo) FindByCompanyCodes(ctx context.Context, codes []
|
|||||||
|
|
||||||
func (r *passwordLoginUserRepo) Delete(ctx context.Context, id string) error { return nil }
|
func (r *passwordLoginUserRepo) Delete(ctx context.Context, id string) error { return nil }
|
||||||
|
|
||||||
|
func (r *passwordLoginUserRepo) DB() *gorm.DB { return nil }
|
||||||
|
|
||||||
func (r *passwordLoginUserRepo) UpdateUserLoginIDs(ctx context.Context, userID string, loginIDs []domain.UserLoginID) error {
|
func (r *passwordLoginUserRepo) UpdateUserLoginIDs(ctx context.Context, userID string, loginIDs []domain.UserLoginID) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -474,7 +477,7 @@ func runHeadlessPasswordLoginWithAssertionRequest(
|
|||||||
Client: domain.HydraClient{
|
Client: domain.HydraClient{
|
||||||
ClientID: "headless-login-client",
|
ClientID: "headless-login-client",
|
||||||
TokenEndpointAuthMethod: "none",
|
TokenEndpointAuthMethod: "none",
|
||||||
Metadata: map[string]interface{}{
|
Metadata: map[string]any{
|
||||||
"status": "active",
|
"status": "active",
|
||||||
"headless_login_enabled": true,
|
"headless_login_enabled": true,
|
||||||
"headless_token_endpoint_auth_method": "private_key_jwt",
|
"headless_token_endpoint_auth_method": "private_key_jwt",
|
||||||
@@ -579,7 +582,7 @@ func runHeadlessPasswordLoginWithAssertionAndLoggerRequest(
|
|||||||
Client: domain.HydraClient{
|
Client: domain.HydraClient{
|
||||||
ClientID: "headless-login-client",
|
ClientID: "headless-login-client",
|
||||||
TokenEndpointAuthMethod: "none",
|
TokenEndpointAuthMethod: "none",
|
||||||
Metadata: map[string]interface{}{
|
Metadata: map[string]any{
|
||||||
"status": "active",
|
"status": "active",
|
||||||
"headless_login_enabled": true,
|
"headless_login_enabled": true,
|
||||||
"headless_token_endpoint_auth_method": "private_key_jwt",
|
"headless_token_endpoint_auth_method": "private_key_jwt",
|
||||||
@@ -667,7 +670,7 @@ func TestPasswordLogin_OIDC_Success(t *testing.T) {
|
|||||||
}
|
}
|
||||||
json.NewEncoder(w).Encode(domain.HydraLoginRequest{
|
json.NewEncoder(w).Encode(domain.HydraLoginRequest{
|
||||||
Challenge: challenge,
|
Challenge: challenge,
|
||||||
Client: domain.HydraClient{ClientID: "client-1", Metadata: map[string]interface{}{"status": "active"}},
|
Client: domain.HydraClient{ClientID: "client-1", Metadata: map[string]any{"status": "active"}},
|
||||||
})
|
})
|
||||||
case strings.Contains(r.URL.Path, "/oauth2/auth/requests/login/accept") && r.Method == http.MethodPut:
|
case strings.Contains(r.URL.Path, "/oauth2/auth/requests/login/accept") && r.Method == http.MethodPut:
|
||||||
// AcceptLoginRequest
|
// AcceptLoginRequest
|
||||||
@@ -710,7 +713,7 @@ func TestPasswordLogin_OIDC_Success(t *testing.T) {
|
|||||||
t.Fatalf("expected 200, got %d, body: %s", resp.StatusCode, string(bodyBytes))
|
t.Fatalf("expected 200, got %d, body: %s", resp.StatusCode, string(bodyBytes))
|
||||||
}
|
}
|
||||||
|
|
||||||
var got map[string]interface{}
|
var got map[string]any
|
||||||
json.NewDecoder(resp.Body).Decode(&got)
|
json.NewDecoder(resp.Body).Decode(&got)
|
||||||
if got["redirectTo"] != "http://rp/cb" {
|
if got["redirectTo"] != "http://rp/cb" {
|
||||||
t.Errorf("expected redirectTo http://rp/cb, got %v", got["redirectTo"])
|
t.Errorf("expected redirectTo http://rp/cb, got %v", got["redirectTo"])
|
||||||
@@ -738,7 +741,7 @@ func TestPasswordLogin_OIDC_AuditIncludesClientMetadata(t *testing.T) {
|
|||||||
Client: domain.HydraClient{
|
Client: domain.HydraClient{
|
||||||
ClientID: "devfront",
|
ClientID: "devfront",
|
||||||
ClientName: "DevFront",
|
ClientName: "DevFront",
|
||||||
Metadata: map[string]interface{}{"status": "active"},
|
Metadata: map[string]any{"status": "active"},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
case strings.Contains(r.URL.Path, "/oauth2/auth/requests/login/accept") && r.Method == http.MethodPut:
|
case strings.Contains(r.URL.Path, "/oauth2/auth/requests/login/accept") && r.Method == http.MethodPut:
|
||||||
@@ -902,7 +905,7 @@ func TestHeadlessPasswordLogin_HeadlessLoginClientSuccess(t *testing.T) {
|
|||||||
Client: domain.HydraClient{
|
Client: domain.HydraClient{
|
||||||
ClientID: "headless-login-client",
|
ClientID: "headless-login-client",
|
||||||
TokenEndpointAuthMethod: "none",
|
TokenEndpointAuthMethod: "none",
|
||||||
Metadata: map[string]interface{}{
|
Metadata: map[string]any{
|
||||||
"status": "active",
|
"status": "active",
|
||||||
"headless_login_enabled": true,
|
"headless_login_enabled": true,
|
||||||
"headless_token_endpoint_auth_method": "private_key_jwt",
|
"headless_token_endpoint_auth_method": "private_key_jwt",
|
||||||
@@ -958,7 +961,7 @@ func TestHeadlessPasswordLogin_HeadlessLoginClientSuccess(t *testing.T) {
|
|||||||
t.Fatalf("expected 200, got %d, body: %s", resp.StatusCode, string(bodyBytes))
|
t.Fatalf("expected 200, got %d, body: %s", resp.StatusCode, string(bodyBytes))
|
||||||
}
|
}
|
||||||
|
|
||||||
var got map[string]interface{}
|
var got map[string]any
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
|
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
|
||||||
t.Fatalf("failed to decode response: %v", err)
|
t.Fatalf("failed to decode response: %v", err)
|
||||||
}
|
}
|
||||||
@@ -1005,7 +1008,7 @@ func TestHeadlessPasswordLogin_OIDCSubjectConflictBlocksMixedRP(t *testing.T) {
|
|||||||
Client: domain.HydraClient{
|
Client: domain.HydraClient{
|
||||||
ClientID: "headless-login-client",
|
ClientID: "headless-login-client",
|
||||||
TokenEndpointAuthMethod: "none",
|
TokenEndpointAuthMethod: "none",
|
||||||
Metadata: map[string]interface{}{
|
Metadata: map[string]any{
|
||||||
"status": "active",
|
"status": "active",
|
||||||
"headless_login_enabled": true,
|
"headless_login_enabled": true,
|
||||||
"headless_token_endpoint_auth_method": "private_key_jwt",
|
"headless_token_endpoint_auth_method": "private_key_jwt",
|
||||||
@@ -1051,7 +1054,7 @@ func TestHeadlessPasswordLogin_OIDCSubjectConflictBlocksMixedRP(t *testing.T) {
|
|||||||
require.Equal(t, http.StatusConflict, resp.StatusCode)
|
require.Equal(t, http.StatusConflict, resp.StatusCode)
|
||||||
require.False(t, acceptCalled)
|
require.False(t, acceptCalled)
|
||||||
require.Empty(t, resp.Cookies())
|
require.Empty(t, resp.Cookies())
|
||||||
var got map[string]interface{}
|
var got map[string]any
|
||||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
|
||||||
require.Equal(t, "oidc_subject_conflict", got["code"])
|
require.Equal(t, "oidc_subject_conflict", got["code"])
|
||||||
require.Equal(t, "redirect_to_userfront_login", got["recommendedAction"])
|
require.Equal(t, "redirect_to_userfront_login", got["recommendedAction"])
|
||||||
@@ -1090,7 +1093,7 @@ func TestHeadlessPasswordLogin_OIDCSubjectSameAllowsMixedRP(t *testing.T) {
|
|||||||
Client: domain.HydraClient{
|
Client: domain.HydraClient{
|
||||||
ClientID: "headless-login-client",
|
ClientID: "headless-login-client",
|
||||||
TokenEndpointAuthMethod: "none",
|
TokenEndpointAuthMethod: "none",
|
||||||
Metadata: map[string]interface{}{
|
Metadata: map[string]any{
|
||||||
"status": "active",
|
"status": "active",
|
||||||
"headless_login_enabled": true,
|
"headless_login_enabled": true,
|
||||||
"headless_token_endpoint_auth_method": "private_key_jwt",
|
"headless_token_endpoint_auth_method": "private_key_jwt",
|
||||||
@@ -1134,7 +1137,7 @@ func TestHeadlessPasswordLogin_OIDCSubjectSameAllowsMixedRP(t *testing.T) {
|
|||||||
|
|
||||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
require.Empty(t, resp.Cookies())
|
require.Empty(t, resp.Cookies())
|
||||||
var got map[string]interface{}
|
var got map[string]any
|
||||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
|
||||||
require.Equal(t, "http://rp/cb", got["redirectTo"])
|
require.Equal(t, "http://rp/cb", got["redirectTo"])
|
||||||
require.Nil(t, got["sessionJwt"])
|
require.Nil(t, got["sessionJwt"])
|
||||||
@@ -1161,7 +1164,7 @@ func TestHeadlessPasswordLogin_AuditIncludesClientMetadata(t *testing.T) {
|
|||||||
ClientID: "headless-login-client",
|
ClientID: "headless-login-client",
|
||||||
ClientName: "Headless Login Portal",
|
ClientName: "Headless Login Portal",
|
||||||
TokenEndpointAuthMethod: "none",
|
TokenEndpointAuthMethod: "none",
|
||||||
Metadata: map[string]interface{}{
|
Metadata: map[string]any{
|
||||||
"status": "active",
|
"status": "active",
|
||||||
"headless_login_enabled": true,
|
"headless_login_enabled": true,
|
||||||
"headless_token_endpoint_auth_method": "private_key_jwt",
|
"headless_token_endpoint_auth_method": "private_key_jwt",
|
||||||
@@ -1294,7 +1297,7 @@ func TestHeadlessPasswordLogin_IgnoresInlineHeadlessJWKSWhenJWKSURIIsConfigured(
|
|||||||
Client: domain.HydraClient{
|
Client: domain.HydraClient{
|
||||||
ClientID: "headless-login-client",
|
ClientID: "headless-login-client",
|
||||||
TokenEndpointAuthMethod: "none",
|
TokenEndpointAuthMethod: "none",
|
||||||
Metadata: map[string]interface{}{
|
Metadata: map[string]any{
|
||||||
"status": "active",
|
"status": "active",
|
||||||
"headless_login_enabled": true,
|
"headless_login_enabled": true,
|
||||||
"headless_token_endpoint_auth_method": "private_key_jwt",
|
"headless_token_endpoint_auth_method": "private_key_jwt",
|
||||||
@@ -1395,7 +1398,7 @@ func TestHeadlessPasswordLogin_RefreshesJWKSWhenSignatureFailsForCachedKid(t *te
|
|||||||
Client: domain.HydraClient{
|
Client: domain.HydraClient{
|
||||||
ClientID: "headless-login-client",
|
ClientID: "headless-login-client",
|
||||||
TokenEndpointAuthMethod: "none",
|
TokenEndpointAuthMethod: "none",
|
||||||
Metadata: map[string]interface{}{
|
Metadata: map[string]any{
|
||||||
"status": "active",
|
"status": "active",
|
||||||
"headless_login_enabled": true,
|
"headless_login_enabled": true,
|
||||||
"headless_token_endpoint_auth_method": "private_key_jwt",
|
"headless_token_endpoint_auth_method": "private_key_jwt",
|
||||||
@@ -1494,7 +1497,7 @@ func TestHeadlessPasswordLogin_MissingClientAssertionRejected(t *testing.T) {
|
|||||||
Client: domain.HydraClient{
|
Client: domain.HydraClient{
|
||||||
ClientID: "headless-login-client",
|
ClientID: "headless-login-client",
|
||||||
TokenEndpointAuthMethod: "none",
|
TokenEndpointAuthMethod: "none",
|
||||||
Metadata: map[string]interface{}{
|
Metadata: map[string]any{
|
||||||
"status": "active",
|
"status": "active",
|
||||||
"headless_login_enabled": true,
|
"headless_login_enabled": true,
|
||||||
"headless_token_endpoint_auth_method": "private_key_jwt",
|
"headless_token_endpoint_auth_method": "private_key_jwt",
|
||||||
@@ -1573,7 +1576,7 @@ func TestHeadlessPasswordLogin_InvalidClientAssertionRejected(t *testing.T) {
|
|||||||
Client: domain.HydraClient{
|
Client: domain.HydraClient{
|
||||||
ClientID: "headless-login-client",
|
ClientID: "headless-login-client",
|
||||||
TokenEndpointAuthMethod: "none",
|
TokenEndpointAuthMethod: "none",
|
||||||
Metadata: map[string]interface{}{
|
Metadata: map[string]any{
|
||||||
"status": "active",
|
"status": "active",
|
||||||
"headless_login_enabled": true,
|
"headless_login_enabled": true,
|
||||||
"headless_token_endpoint_auth_method": "private_key_jwt",
|
"headless_token_endpoint_auth_method": "private_key_jwt",
|
||||||
@@ -1995,7 +1998,7 @@ func TestHeadlessPasswordLogin_HeadlessDisabledRejected(t *testing.T) {
|
|||||||
Client: domain.HydraClient{
|
Client: domain.HydraClient{
|
||||||
ClientID: "headless-login-client",
|
ClientID: "headless-login-client",
|
||||||
TokenEndpointAuthMethod: "none",
|
TokenEndpointAuthMethod: "none",
|
||||||
Metadata: map[string]interface{}{
|
Metadata: map[string]any{
|
||||||
"status": "active",
|
"status": "active",
|
||||||
"headless_jwks_uri": "https://rp.example.com/.well-known/jwks.json",
|
"headless_jwks_uri": "https://rp.example.com/.well-known/jwks.json",
|
||||||
"headless_token_endpoint_auth_method": "private_key_jwt",
|
"headless_token_endpoint_auth_method": "private_key_jwt",
|
||||||
@@ -2048,7 +2051,7 @@ func TestHeadlessPasswordLogin_ClientIDMismatchRejected(t *testing.T) {
|
|||||||
Client: domain.HydraClient{
|
Client: domain.HydraClient{
|
||||||
ClientID: "other-rp",
|
ClientID: "other-rp",
|
||||||
TokenEndpointAuthMethod: "none",
|
TokenEndpointAuthMethod: "none",
|
||||||
Metadata: map[string]interface{}{
|
Metadata: map[string]any{
|
||||||
"status": "active",
|
"status": "active",
|
||||||
"headless_login_enabled": true,
|
"headless_login_enabled": true,
|
||||||
"headless_token_endpoint_auth_method": "private_key_jwt",
|
"headless_token_endpoint_auth_method": "private_key_jwt",
|
||||||
@@ -2102,7 +2105,7 @@ func TestPasswordLogin_OIDC_InactiveClient(t *testing.T) {
|
|||||||
hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if strings.Contains(r.URL.Path, "/oauth2/auth/requests/login") && r.Method == http.MethodGet {
|
if strings.Contains(r.URL.Path, "/oauth2/auth/requests/login") && r.Method == http.MethodGet {
|
||||||
json.NewEncoder(w).Encode(domain.HydraLoginRequest{
|
json.NewEncoder(w).Encode(domain.HydraLoginRequest{
|
||||||
Client: domain.HydraClient{ClientID: "client-inactive", Metadata: map[string]interface{}{"status": "inactive"}},
|
Client: domain.HydraClient{ClientID: "client-inactive", Metadata: map[string]any{"status": "inactive"}},
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -2220,7 +2223,7 @@ func TestPasswordLogin_SharedBrowserSameSubjectAllowed(t *testing.T) {
|
|||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
var got map[string]interface{}
|
var got map[string]any
|
||||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
|
||||||
require.Equal(t, "valid-jwt", got["sessionJwt"])
|
require.Equal(t, "valid-jwt", got["sessionJwt"])
|
||||||
mockIdp.AssertExpectations(t)
|
mockIdp.AssertExpectations(t)
|
||||||
@@ -2259,7 +2262,7 @@ func TestPasswordLogin_SharedBrowserDifferentSubjectConflicts(t *testing.T) {
|
|||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
require.Equal(t, http.StatusConflict, resp.StatusCode)
|
require.Equal(t, http.StatusConflict, resp.StatusCode)
|
||||||
var got map[string]interface{}
|
var got map[string]any
|
||||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
|
||||||
require.Equal(t, "session_subject_conflict", got["code"])
|
require.Equal(t, "session_subject_conflict", got["code"])
|
||||||
require.Empty(t, resp.Cookies())
|
require.Empty(t, resp.Cookies())
|
||||||
|
|||||||
@@ -33,10 +33,10 @@ func TestAcceptOidcLoginRequest_CookieOnly(t *testing.T) {
|
|||||||
if r.Header.Get("Cookie") == "" {
|
if r.Header.Get("Cookie") == "" {
|
||||||
return httpResponse(r, http.StatusUnauthorized, "missing cookie"), nil
|
return httpResponse(r, http.StatusUnauthorized, "missing cookie"), nil
|
||||||
}
|
}
|
||||||
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
|
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||||
"identity": map[string]interface{}{
|
"identity": map[string]any{
|
||||||
"id": "kratos-123",
|
"id": "kratos-123",
|
||||||
"traits": map[string]interface{}{},
|
"traits": map[string]any{},
|
||||||
},
|
},
|
||||||
}), nil
|
}), nil
|
||||||
case "hydra.test":
|
case "hydra.test":
|
||||||
@@ -45,7 +45,7 @@ func TestAcceptOidcLoginRequest_CookieOnly(t *testing.T) {
|
|||||||
}
|
}
|
||||||
gotChallenge = r.URL.Query().Get("login_challenge")
|
gotChallenge = r.URL.Query().Get("login_challenge")
|
||||||
body, _ := io.ReadAll(r.Body)
|
body, _ := io.ReadAll(r.Body)
|
||||||
var payload map[string]interface{}
|
var payload map[string]any
|
||||||
_ = json.Unmarshal(body, &payload)
|
_ = json.Unmarshal(body, &payload)
|
||||||
if subject, ok := payload["subject"].(string); ok {
|
if subject, ok := payload["subject"].(string); ok {
|
||||||
gotSubject = subject
|
gotSubject = subject
|
||||||
@@ -117,10 +117,10 @@ func TestAcceptOidcLoginRequest_TokenFallbackToCookie(t *testing.T) {
|
|||||||
if r.Header.Get("Cookie") == "" {
|
if r.Header.Get("Cookie") == "" {
|
||||||
return httpResponse(r, http.StatusUnauthorized, "missing cookie"), nil
|
return httpResponse(r, http.StatusUnauthorized, "missing cookie"), nil
|
||||||
}
|
}
|
||||||
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
|
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||||
"identity": map[string]interface{}{
|
"identity": map[string]any{
|
||||||
"id": "kratos-456",
|
"id": "kratos-456",
|
||||||
"traits": map[string]interface{}{},
|
"traits": map[string]any{},
|
||||||
},
|
},
|
||||||
}), nil
|
}), nil
|
||||||
case "hydra.test":
|
case "hydra.test":
|
||||||
@@ -128,7 +128,7 @@ func TestAcceptOidcLoginRequest_TokenFallbackToCookie(t *testing.T) {
|
|||||||
return httpResponse(r, http.StatusNotFound, "not found"), nil
|
return httpResponse(r, http.StatusNotFound, "not found"), nil
|
||||||
}
|
}
|
||||||
body, _ := io.ReadAll(r.Body)
|
body, _ := io.ReadAll(r.Body)
|
||||||
var payload map[string]interface{}
|
var payload map[string]any
|
||||||
_ = json.Unmarshal(body, &payload)
|
_ = json.Unmarshal(body, &payload)
|
||||||
if subject, ok := payload["subject"].(string); ok {
|
if subject, ok := payload["subject"].(string); ok {
|
||||||
gotSubject = subject
|
gotSubject = subject
|
||||||
|
|||||||
@@ -23,10 +23,10 @@ func TestHandleKratosCourierRelay_Email(t *testing.T) {
|
|||||||
app.Post("/api/v1/auth/kratos/courier", h.HandleKratosCourierRelay)
|
app.Post("/api/v1/auth/kratos/courier", h.HandleKratosCourierRelay)
|
||||||
|
|
||||||
// Simulate Kratos Courier Request for Email
|
// Simulate Kratos Courier Request for Email
|
||||||
reqBody := map[string]interface{}{
|
reqBody := map[string]any{
|
||||||
"recipient": "user@example.com",
|
"recipient": "user@example.com",
|
||||||
"template_type": "verification_code",
|
"template_type": "verification_code",
|
||||||
"template_data": map[string]interface{}{
|
"template_data": map[string]any{
|
||||||
"verification_code": "123456",
|
"verification_code": "123456",
|
||||||
},
|
},
|
||||||
"subject": "Verify your email",
|
"subject": "Verify your email",
|
||||||
@@ -50,7 +50,7 @@ func TestVerifySignupCode_Success(t *testing.T) {
|
|||||||
|
|
||||||
// Mock stored code in redis
|
// Mock stored code in redis
|
||||||
// signup:email:user@test.com -> {"code":"654321", "verified":false, "expires_at":...}
|
// signup:email:user@test.com -> {"code":"654321", "verified":false, "expires_at":...}
|
||||||
state := map[string]interface{}{
|
state := map[string]any{
|
||||||
"code": "654321",
|
"code": "654321",
|
||||||
"verified": false,
|
"verified": false,
|
||||||
"expires_at": 9999999999, // far future
|
"expires_at": 9999999999, // far future
|
||||||
@@ -71,13 +71,13 @@ func TestVerifySignupCode_Success(t *testing.T) {
|
|||||||
resp, _ := app.Test(req, -1)
|
resp, _ := app.Test(req, -1)
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var res map[string]interface{}
|
var res map[string]any
|
||||||
json.NewDecoder(resp.Body).Decode(&res)
|
json.NewDecoder(resp.Body).Decode(&res)
|
||||||
assert.True(t, res["success"].(bool))
|
assert.True(t, res["success"].(bool))
|
||||||
|
|
||||||
// Check redis state updated to verified
|
// Check redis state updated to verified
|
||||||
val, _ := redis.Get("signup:email:user@test.com")
|
val, _ := redis.Get("signup:email:user@test.com")
|
||||||
var updatedState map[string]interface{}
|
var updatedState map[string]any
|
||||||
json.Unmarshal([]byte(val), &updatedState)
|
json.Unmarshal([]byte(val), &updatedState)
|
||||||
assert.True(t, updatedState["verified"].(bool))
|
assert.True(t, updatedState["verified"].(bool))
|
||||||
}
|
}
|
||||||
@@ -90,7 +90,7 @@ func TestVerifySignupCode_Invalid(t *testing.T) {
|
|||||||
app := fiber.New()
|
app := fiber.New()
|
||||||
app.Post("/api/v1/auth/signup/verify", h.VerifySignupCode)
|
app.Post("/api/v1/auth/signup/verify", h.VerifySignupCode)
|
||||||
|
|
||||||
stateJSON, _ := json.Marshal(map[string]interface{}{
|
stateJSON, _ := json.Marshal(map[string]any{
|
||||||
"code": "111111",
|
"code": "111111",
|
||||||
"expires_at": 9999999999,
|
"expires_at": 9999999999,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"maps"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -33,7 +34,7 @@ func (r *recordingUpdateMeUserRepo) UpdateUserLoginIDs(ctx context.Context, user
|
|||||||
func TestUpdateMe_InvalidatesProfileCacheForTokenSession(t *testing.T) {
|
func TestUpdateMe_InvalidatesProfileCacheForTokenSession(t *testing.T) {
|
||||||
token := "token-abc"
|
token := "token-abc"
|
||||||
identityID := "user-1"
|
identityID := "user-1"
|
||||||
traits := map[string]interface{}{
|
traits := map[string]any{
|
||||||
"email": "qa@example.com",
|
"email": "qa@example.com",
|
||||||
"name": "QA User",
|
"name": "QA User",
|
||||||
"phone_number": "+821012345678",
|
"phone_number": "+821012345678",
|
||||||
@@ -51,8 +52,8 @@ func TestUpdateMe_InvalidatesProfileCacheForTokenSession(t *testing.T) {
|
|||||||
if r.Header.Get("X-Session-Token") != token {
|
if r.Header.Get("X-Session-Token") != token {
|
||||||
return httpResponse(r, http.StatusUnauthorized, `{"error":"invalid token"}`), nil
|
return httpResponse(r, http.StatusUnauthorized, `{"error":"invalid token"}`), nil
|
||||||
}
|
}
|
||||||
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
|
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||||
"identity": map[string]interface{}{
|
"identity": map[string]any{
|
||||||
"id": identityID,
|
"id": identityID,
|
||||||
"traits": traits,
|
"traits": traits,
|
||||||
},
|
},
|
||||||
@@ -62,14 +63,12 @@ func TestUpdateMe_InvalidatesProfileCacheForTokenSession(t *testing.T) {
|
|||||||
r.URL.Path == "/admin/identities/"+identityID &&
|
r.URL.Path == "/admin/identities/"+identityID &&
|
||||||
r.Method == http.MethodPut:
|
r.Method == http.MethodPut:
|
||||||
var payload struct {
|
var payload struct {
|
||||||
Traits map[string]interface{} `json:"traits"`
|
Traits map[string]any `json:"traits"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||||
return httpResponse(r, http.StatusBadRequest, `{"error":"invalid body"}`), nil
|
return httpResponse(r, http.StatusBadRequest, `{"error":"invalid body"}`), nil
|
||||||
}
|
}
|
||||||
for k, v := range payload.Traits {
|
maps.Copy(traits, payload.Traits)
|
||||||
traits[k] = v
|
|
||||||
}
|
|
||||||
return httpResponse(r, http.StatusOK, `{"ok":true}`), nil
|
return httpResponse(r, http.StatusOK, `{"ok":true}`), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,7 +92,7 @@ func TestUpdateMe_InvalidatesProfileCacheForTokenSession(t *testing.T) {
|
|||||||
getResp1, err := app.Test(getReq1, -1)
|
getResp1, err := app.Test(getReq1, -1)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, http.StatusOK, getResp1.StatusCode)
|
require.Equal(t, http.StatusOK, getResp1.StatusCode)
|
||||||
var profile1 map[string]interface{}
|
var profile1 map[string]any
|
||||||
require.NoError(t, json.NewDecoder(getResp1.Body).Decode(&profile1))
|
require.NoError(t, json.NewDecoder(getResp1.Body).Decode(&profile1))
|
||||||
require.Equal(t, "Old Dept", profile1["department"])
|
require.Equal(t, "Old Dept", profile1["department"])
|
||||||
|
|
||||||
@@ -121,7 +120,7 @@ func TestUpdateMe_InvalidatesProfileCacheForTokenSession(t *testing.T) {
|
|||||||
getResp2, err := app.Test(getReq2, -1)
|
getResp2, err := app.Test(getReq2, -1)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, http.StatusOK, getResp2.StatusCode)
|
require.Equal(t, http.StatusOK, getResp2.StatusCode)
|
||||||
var profile2 map[string]interface{}
|
var profile2 map[string]any
|
||||||
require.NoError(t, json.NewDecoder(getResp2.Body).Decode(&profile2))
|
require.NoError(t, json.NewDecoder(getResp2.Body).Decode(&profile2))
|
||||||
require.Equal(t, "New Dept", profile2["department"])
|
require.Equal(t, "New Dept", profile2["department"])
|
||||||
}
|
}
|
||||||
@@ -129,7 +128,7 @@ func TestUpdateMe_InvalidatesProfileCacheForTokenSession(t *testing.T) {
|
|||||||
func TestUpdateMe_SyncsLocalReadModelFields(t *testing.T) {
|
func TestUpdateMe_SyncsLocalReadModelFields(t *testing.T) {
|
||||||
token := "token-sync"
|
token := "token-sync"
|
||||||
identityID := "user-sync"
|
identityID := "user-sync"
|
||||||
traits := map[string]interface{}{
|
traits := map[string]any{
|
||||||
"email": "sync@example.com",
|
"email": "sync@example.com",
|
||||||
"name": "Old Name",
|
"name": "Old Name",
|
||||||
"phone_number": "+821012345678",
|
"phone_number": "+821012345678",
|
||||||
@@ -148,8 +147,8 @@ func TestUpdateMe_SyncsLocalReadModelFields(t *testing.T) {
|
|||||||
if r.Header.Get("X-Session-Token") != token {
|
if r.Header.Get("X-Session-Token") != token {
|
||||||
return httpResponse(r, http.StatusUnauthorized, `{"error":"invalid token"}`), nil
|
return httpResponse(r, http.StatusUnauthorized, `{"error":"invalid token"}`), nil
|
||||||
}
|
}
|
||||||
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
|
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||||
"identity": map[string]interface{}{
|
"identity": map[string]any{
|
||||||
"id": identityID,
|
"id": identityID,
|
||||||
"traits": traits,
|
"traits": traits,
|
||||||
},
|
},
|
||||||
@@ -159,14 +158,12 @@ func TestUpdateMe_SyncsLocalReadModelFields(t *testing.T) {
|
|||||||
r.URL.Path == "/admin/identities/"+identityID &&
|
r.URL.Path == "/admin/identities/"+identityID &&
|
||||||
r.Method == http.MethodPut:
|
r.Method == http.MethodPut:
|
||||||
var payload struct {
|
var payload struct {
|
||||||
Traits map[string]interface{} `json:"traits"`
|
Traits map[string]any `json:"traits"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||||
return httpResponse(r, http.StatusBadRequest, `{"error":"invalid body"}`), nil
|
return httpResponse(r, http.StatusBadRequest, `{"error":"invalid body"}`), nil
|
||||||
}
|
}
|
||||||
for k, v := range payload.Traits {
|
maps.Copy(traits, payload.Traits)
|
||||||
traits[k] = v
|
|
||||||
}
|
|
||||||
return httpResponse(r, http.StatusOK, `{"ok":true}`), nil
|
return httpResponse(r, http.StatusOK, `{"ok":true}`), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,7 +184,7 @@ func TestUpdateMe_SyncsLocalReadModelFields(t *testing.T) {
|
|||||||
app := fiber.New()
|
app := fiber.New()
|
||||||
app.Put("/api/v1/user/me", h.UpdateMe)
|
app.Put("/api/v1/user/me", h.UpdateMe)
|
||||||
|
|
||||||
updateBody, _ := json.Marshal(map[string]interface{}{
|
updateBody, _ := json.Marshal(map[string]any{
|
||||||
"name": "New Name",
|
"name": "New Name",
|
||||||
"phone": "01087654321",
|
"phone": "01087654321",
|
||||||
"department": "New Dept",
|
"department": "New Dept",
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ func TestQRLoginFlow_Success(t *testing.T) {
|
|||||||
resp, _ := app.Test(req, -1)
|
resp, _ := app.Test(req, -1)
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var initResp map[string]interface{}
|
var initResp map[string]any
|
||||||
json.NewDecoder(resp.Body).Decode(&initResp)
|
json.NewDecoder(resp.Body).Decode(&initResp)
|
||||||
pendingRef := initResp["pendingRef"].(string)
|
pendingRef := initResp["pendingRef"].(string)
|
||||||
|
|
||||||
@@ -80,7 +80,7 @@ func TestQRLoginFlow_Success(t *testing.T) {
|
|||||||
|
|
||||||
// Expect authorization_pending (400)
|
// Expect authorization_pending (400)
|
||||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
var pollResp map[string]interface{}
|
var pollResp map[string]any
|
||||||
json.NewDecoder(resp.Body).Decode(&pollResp)
|
json.NewDecoder(resp.Body).Decode(&pollResp)
|
||||||
assert.Equal(t, "authorization_pending", pollResp["error"])
|
assert.Equal(t, "authorization_pending", pollResp["error"])
|
||||||
assert.Equal(t, "authorization_pending", pollResp["code"])
|
assert.Equal(t, "authorization_pending", pollResp["code"])
|
||||||
@@ -99,7 +99,7 @@ func TestQRLoginFlow_Success(t *testing.T) {
|
|||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var successResp map[string]interface{}
|
var successResp map[string]any
|
||||||
json.NewDecoder(resp.Body).Decode(&successResp)
|
json.NewDecoder(resp.Body).Decode(&successResp)
|
||||||
assert.Equal(t, "ok", successResp["status"])
|
assert.Equal(t, "ok", successResp["status"])
|
||||||
assert.Equal(t, "mock-session-jwt", successResp["sessionJwt"])
|
assert.Equal(t, "mock-session-jwt", successResp["sessionJwt"])
|
||||||
@@ -121,10 +121,10 @@ func TestScanQRLogin_Success(t *testing.T) {
|
|||||||
|
|
||||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
if r.URL.Path == "/sessions/whoami" {
|
if r.URL.Path == "/sessions/whoami" {
|
||||||
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
|
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||||
"identity": map[string]interface{}{
|
"identity": map[string]any{
|
||||||
"id": "user-123",
|
"id": "user-123",
|
||||||
"traits": map[string]interface{}{
|
"traits": map[string]any{
|
||||||
"email": "user@example.com",
|
"email": "user@example.com",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -153,20 +153,20 @@ func TestResolveConsentSubjects_TokenAndCookie(t *testing.T) {
|
|||||||
|
|
||||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
if r.Header.Get("X-Session-Token") == "token-123" {
|
if r.Header.Get("X-Session-Token") == "token-123" {
|
||||||
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
|
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||||
"identity": map[string]interface{}{
|
"identity": map[string]any{
|
||||||
"id": "user-token",
|
"id": "user-token",
|
||||||
"traits": map[string]interface{}{
|
"traits": map[string]any{
|
||||||
"email": "token@test.com",
|
"email": "token@test.com",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}), nil
|
}), nil
|
||||||
}
|
}
|
||||||
if r.Header.Get("Cookie") == "ory_kratos_session=cookie-123" {
|
if r.Header.Get("Cookie") == "ory_kratos_session=cookie-123" {
|
||||||
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
|
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||||
"identity": map[string]interface{}{
|
"identity": map[string]any{
|
||||||
"id": "user-cookie",
|
"id": "user-cookie",
|
||||||
"traits": map[string]interface{}{
|
"traits": map[string]any{
|
||||||
"email": "cookie@test.com",
|
"email": "cookie@test.com",
|
||||||
"phone": "010-1234-5678",
|
"phone": "010-1234-5678",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ func TestSignup_TenantSlugValidation(t *testing.T) {
|
|||||||
app.Post("/signup", h.Signup)
|
app.Post("/signup", h.Signup)
|
||||||
|
|
||||||
// Prepare mock state (already verified email/phone)
|
// Prepare mock state (already verified email/phone)
|
||||||
verifiedState, _ := json.Marshal(map[string]interface{}{
|
verifiedState, _ := json.Marshal(map[string]any{
|
||||||
"verified": true,
|
"verified": true,
|
||||||
"expires_at": time.Now().Add(time.Hour).Unix(),
|
"expires_at": time.Now().Add(time.Hour).Unix(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -17,9 +17,9 @@ const (
|
|||||||
clientAllowedTenantsKey = "allowed_tenants"
|
clientAllowedTenantsKey = "allowed_tenants"
|
||||||
)
|
)
|
||||||
|
|
||||||
func normalizeClientTenantAccessMetadata(metadata map[string]interface{}) (map[string]interface{}, error) {
|
func normalizeClientTenantAccessMetadata(metadata map[string]any) (map[string]any, error) {
|
||||||
if metadata == nil {
|
if metadata == nil {
|
||||||
metadata = map[string]interface{}{}
|
metadata = map[string]any{}
|
||||||
}
|
}
|
||||||
|
|
||||||
restricted := readMetadataBoolValue(metadata, clientTenantAccessRestrictedKey)
|
restricted := readMetadataBoolValue(metadata, clientTenantAccessRestrictedKey)
|
||||||
@@ -49,7 +49,7 @@ func normalizeClientTenantAccessMetadata(metadata map[string]interface{}) (map[s
|
|||||||
return metadata, nil
|
return metadata, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func clientTenantAccessRestricted(metadata map[string]interface{}) bool {
|
func clientTenantAccessRestricted(metadata map[string]any) bool {
|
||||||
if metadata == nil {
|
if metadata == nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -59,7 +59,7 @@ func clientTenantAccessRestricted(metadata map[string]interface{}) bool {
|
|||||||
return len(normalizeMetadataStringSlice(metadata[clientAllowedTenantsKey])) > 0
|
return len(normalizeMetadataStringSlice(metadata[clientAllowedTenantsKey])) > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func clientAllowedTenants(metadata map[string]interface{}) []string {
|
func clientAllowedTenants(metadata map[string]any) []string {
|
||||||
if metadata == nil {
|
if metadata == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -250,7 +250,7 @@ func TestGetConsentRequest_DeniesRestrictedClientWhenProfileResolutionFails(t *t
|
|||||||
mockKratos := new(MockKratosAdminService)
|
mockKratos := new(MockKratosAdminService)
|
||||||
mockKratos.On("GetIdentity", mock.Anything, "user-123").Return(&service.KratosIdentity{
|
mockKratos.On("GetIdentity", mock.Anything, "user-123").Return(&service.KratosIdentity{
|
||||||
ID: "user-123",
|
ID: "user-123",
|
||||||
Traits: map[string]interface{}{
|
Traits: map[string]any{
|
||||||
"email": "user@test.com",
|
"email": "user@test.com",
|
||||||
"tenant_id": "tenant-a",
|
"tenant_id": "tenant-a",
|
||||||
"companyCode": "tenant-a",
|
"companyCode": "tenant-a",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"slices"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -94,11 +95,8 @@ func (m *mockAuditRepo) FindByUserAndEvents(ctx context.Context, userID string,
|
|||||||
var results []domain.AuditLog
|
var results []domain.AuditLog
|
||||||
for _, log := range m.logs {
|
for _, log := range m.logs {
|
||||||
if log.UserID == userID {
|
if log.UserID == userID {
|
||||||
for _, et := range eventTypes {
|
if slices.Contains(eventTypes, log.EventType) {
|
||||||
if log.EventType == et {
|
results = append(results, log)
|
||||||
results = append(results, log)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"maps"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -104,21 +105,21 @@ type devRPUsageDailyResponse struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type clientSummary struct {
|
type clientSummary struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
CreatedAt *time.Time `json:"createdAt,omitempty"`
|
CreatedAt *time.Time `json:"createdAt,omitempty"`
|
||||||
RedirectURIs []string `json:"redirectUris"`
|
RedirectURIs []string `json:"redirectUris"`
|
||||||
Scopes []string `json:"scopes"`
|
Scopes []string `json:"scopes"`
|
||||||
ClientSecret string `json:"clientSecret,omitempty"`
|
ClientSecret string `json:"clientSecret,omitempty"`
|
||||||
TokenEndpointAuthMethod string `json:"tokenEndpointAuthMethod,omitempty"`
|
TokenEndpointAuthMethod string `json:"tokenEndpointAuthMethod,omitempty"`
|
||||||
SkipConsent bool `json:"skipConsent"`
|
SkipConsent bool `json:"skipConsent"`
|
||||||
JwksUri string `json:"jwksUri,omitempty"`
|
JwksUri string `json:"jwksUri,omitempty"`
|
||||||
Jwks interface{} `json:"jwks,omitempty"`
|
Jwks any `json:"jwks,omitempty"`
|
||||||
BackchannelLogoutURI string `json:"backchannelLogoutUri,omitempty"`
|
BackchannelLogoutURI string `json:"backchannelLogoutUri,omitempty"`
|
||||||
BackchannelLogoutSessionRequired bool `json:"backchannelLogoutSessionRequired"`
|
BackchannelLogoutSessionRequired bool `json:"backchannelLogoutSessionRequired"`
|
||||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
Metadata map[string]any `json:"metadata,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type clientListResponse struct {
|
type clientListResponse struct {
|
||||||
@@ -198,21 +199,21 @@ type consentListResponse struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type clientUpsertRequest struct {
|
type clientUpsertRequest struct {
|
||||||
ID *string `json:"id"`
|
ID *string `json:"id"`
|
||||||
Name *string `json:"name"`
|
Name *string `json:"name"`
|
||||||
Type *string `json:"type"`
|
Type *string `json:"type"`
|
||||||
Status *string `json:"status"`
|
Status *string `json:"status"`
|
||||||
RedirectURIs *[]string `json:"redirectUris"`
|
RedirectURIs *[]string `json:"redirectUris"`
|
||||||
Scopes *[]string `json:"scopes"`
|
Scopes *[]string `json:"scopes"`
|
||||||
GrantTypes *[]string `json:"grantTypes"`
|
GrantTypes *[]string `json:"grantTypes"`
|
||||||
ResponseTypes *[]string `json:"responseTypes"`
|
ResponseTypes *[]string `json:"responseTypes"`
|
||||||
TokenEndpointAuthMethod *string `json:"tokenEndpointAuthMethod"`
|
TokenEndpointAuthMethod *string `json:"tokenEndpointAuthMethod"`
|
||||||
SkipConsent *bool `json:"skipConsent"`
|
SkipConsent *bool `json:"skipConsent"`
|
||||||
JwksUri *string `json:"jwksUri"`
|
JwksUri *string `json:"jwksUri"`
|
||||||
Jwks interface{} `json:"jwks"`
|
Jwks any `json:"jwks"`
|
||||||
BackchannelLogoutURI *string `json:"backchannelLogoutUri"`
|
BackchannelLogoutURI *string `json:"backchannelLogoutUri"`
|
||||||
BackchannelLogoutSessionRequired *bool `json:"backchannelLogoutSessionRequired"`
|
BackchannelLogoutSessionRequired *bool `json:"backchannelLogoutSessionRequired"`
|
||||||
Metadata *map[string]interface{} `json:"metadata"`
|
Metadata *map[string]any `json:"metadata"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type normalizedIDTokenClaim struct {
|
type normalizedIDTokenClaim struct {
|
||||||
@@ -303,7 +304,7 @@ func tenantIDFromProfile(profile *domain.UserProfileResponse) string {
|
|||||||
func addClientIDToSet(set map[string]struct{}, raw any) {
|
func addClientIDToSet(set map[string]struct{}, raw any) {
|
||||||
switch value := raw.(type) {
|
switch value := raw.(type) {
|
||||||
case string:
|
case string:
|
||||||
for _, chunk := range strings.Split(value, ",") {
|
for chunk := range strings.SplitSeq(value, ",") {
|
||||||
id := strings.TrimSpace(chunk)
|
id := strings.TrimSpace(chunk)
|
||||||
if id != "" {
|
if id != "" {
|
||||||
set[id] = struct{}{}
|
set[id] = struct{}{}
|
||||||
@@ -672,7 +673,7 @@ func isProtectedSystemClientID(clientID string) bool {
|
|||||||
return ok
|
return ok
|
||||||
}
|
}
|
||||||
|
|
||||||
func tenantAccessPolicyChanged(before, after map[string]interface{}) bool {
|
func tenantAccessPolicyChanged(before, after map[string]any) bool {
|
||||||
if clientTenantAccessRestricted(before) != clientTenantAccessRestricted(after) {
|
if clientTenantAccessRestricted(before) != clientTenantAccessRestricted(after) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -1162,7 +1163,7 @@ func extractAuthClaimsFromBearer(authHeader string) (string, string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var claims map[string]interface{}
|
var claims map[string]any
|
||||||
if err := json.Unmarshal(payload, &claims); err != nil {
|
if err := json.Unmarshal(payload, &claims); err != nil {
|
||||||
return "", ""
|
return "", ""
|
||||||
}
|
}
|
||||||
@@ -1295,10 +1296,7 @@ func (h *DevHandler) ListClients(c *fiber.Ctx) error {
|
|||||||
if offset > len(allItems) {
|
if offset > len(allItems) {
|
||||||
offset = len(allItems)
|
offset = len(allItems)
|
||||||
}
|
}
|
||||||
end := offset + limit
|
end := min(offset+limit, len(allItems))
|
||||||
if end > len(allItems) {
|
|
||||||
end = len(allItems)
|
|
||||||
}
|
|
||||||
items = allItems[offset:end]
|
items = allItems[offset:end]
|
||||||
if len(allItems) > end && len(items) > 0 {
|
if len(allItems) > end && len(items) > 0 {
|
||||||
lastTimestamp, lastID := clientSummaryCursorKey(items[len(items)-1])
|
lastTimestamp, lastID := clientSummaryCursorKey(items[len(items)-1])
|
||||||
@@ -1788,7 +1786,7 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
metadata := mergeMetadata(nil, req.Metadata)
|
metadata := mergeMetadata(nil, req.Metadata)
|
||||||
if metadata == nil {
|
if metadata == nil {
|
||||||
metadata = map[string]interface{}{}
|
metadata = map[string]any{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// [Tenant Isolation] Record owner information
|
// [Tenant Isolation] Record owner information
|
||||||
@@ -1858,11 +1856,11 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
|
|||||||
ResponseTypes: responseTypes,
|
ResponseTypes: responseTypes,
|
||||||
Scope: strings.Join(scopes, " "),
|
Scope: strings.Join(scopes, " "),
|
||||||
TokenEndpointAuthMethod: tokenAuthMethod,
|
TokenEndpointAuthMethod: tokenAuthMethod,
|
||||||
SkipConsent: boolPtr(valueOrBool(req.SkipConsent, true)),
|
SkipConsent: new(valueOrBool(req.SkipConsent, true)),
|
||||||
JWKSUri: jwksURI,
|
JWKSUri: jwksURI,
|
||||||
JWKS: jwks,
|
JWKS: jwks,
|
||||||
BackChannelLogoutURI: backchannelLogoutURI,
|
BackChannelLogoutURI: backchannelLogoutURI,
|
||||||
BackChannelLogoutSessionRequired: boolPtr(backchannelLogoutSessionRequired),
|
BackChannelLogoutSessionRequired: new(backchannelLogoutSessionRequired),
|
||||||
Metadata: metadata,
|
Metadata: metadata,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2005,7 +2003,7 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error {
|
|||||||
metadata := mergeMetadata(current.Metadata, req.Metadata)
|
metadata := mergeMetadata(current.Metadata, req.Metadata)
|
||||||
if status != "" {
|
if status != "" {
|
||||||
if metadata == nil {
|
if metadata == nil {
|
||||||
metadata = map[string]interface{}{}
|
metadata = map[string]any{}
|
||||||
}
|
}
|
||||||
metadata["status"] = status
|
metadata["status"] = status
|
||||||
}
|
}
|
||||||
@@ -2061,11 +2059,11 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error {
|
|||||||
ResponseTypes: derefSlice(req.ResponseTypes, current.ResponseTypes),
|
ResponseTypes: derefSlice(req.ResponseTypes, current.ResponseTypes),
|
||||||
Scope: buildScope(valueOrSlice(req.Scopes, strings.Fields(current.Scope))),
|
Scope: buildScope(valueOrSlice(req.Scopes, strings.Fields(current.Scope))),
|
||||||
TokenEndpointAuthMethod: resolvedTokenAuthMethod,
|
TokenEndpointAuthMethod: resolvedTokenAuthMethod,
|
||||||
SkipConsent: boolPtr(resolvedSkipConsent),
|
SkipConsent: new(resolvedSkipConsent),
|
||||||
JWKSUri: resolvedJWKSURI,
|
JWKSUri: resolvedJWKSURI,
|
||||||
JWKS: resolvedJWKS,
|
JWKS: resolvedJWKS,
|
||||||
BackChannelLogoutURI: strings.TrimSpace(resolvedBackchannelLogoutURI),
|
BackChannelLogoutURI: strings.TrimSpace(resolvedBackchannelLogoutURI),
|
||||||
BackChannelLogoutSessionRequired: boolPtr(resolvedBackchannelLogoutSessionRequired),
|
BackChannelLogoutSessionRequired: new(resolvedBackchannelLogoutSessionRequired),
|
||||||
Metadata: metadata,
|
Metadata: metadata,
|
||||||
}
|
}
|
||||||
if err := validateReservedSystemClientName(updated.ClientID, updated.ClientName); err != nil {
|
if err := validateReservedSystemClientName(updated.ClientID, updated.ClientName); err != nil {
|
||||||
@@ -2359,10 +2357,7 @@ func (h *DevHandler) ListConsents(c *fiber.Ctx) error {
|
|||||||
if offset > len(items) {
|
if offset > len(items) {
|
||||||
offset = len(items)
|
offset = len(items)
|
||||||
}
|
}
|
||||||
end := offset + limit
|
end := min(offset+limit, len(items))
|
||||||
if end > len(items) {
|
|
||||||
end = len(items)
|
|
||||||
}
|
|
||||||
pageItems := items[offset:end]
|
pageItems := items[offset:end]
|
||||||
if len(items) > end && len(pageItems) > 0 {
|
if len(items) > end && len(pageItems) > 0 {
|
||||||
lastTimestamp, lastID := consentSummaryCursorKey(pageItems[len(pageItems)-1])
|
lastTimestamp, lastID := consentSummaryCursorKey(pageItems[len(pageItems)-1])
|
||||||
@@ -2948,7 +2943,7 @@ func (h *DevHandler) mapClientSummary(client domain.HydraClient) clientSummary {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func readMetadataStringValue(metadata map[string]interface{}, key string) string {
|
func readMetadataStringValue(metadata map[string]any, key string) string {
|
||||||
if metadata == nil {
|
if metadata == nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
@@ -2956,7 +2951,7 @@ func readMetadataStringValue(metadata map[string]interface{}, key string) string
|
|||||||
return strings.TrimSpace(raw)
|
return strings.TrimSpace(raw)
|
||||||
}
|
}
|
||||||
|
|
||||||
func readMetadataBoolValue(metadata map[string]interface{}, key string) bool {
|
func readMetadataBoolValue(metadata map[string]any, key string) bool {
|
||||||
if metadata == nil {
|
if metadata == nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -2964,7 +2959,7 @@ func readMetadataBoolValue(metadata map[string]interface{}, key string) bool {
|
|||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
func readStringSliceMetadata(metadata map[string]interface{}, key string) []string {
|
func readStringSliceMetadata(metadata map[string]any, key string) []string {
|
||||||
if metadata == nil {
|
if metadata == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -2981,7 +2976,7 @@ func readStringSliceMetadata(metadata map[string]interface{}, key string) []stri
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
case []interface{}:
|
case []any:
|
||||||
result := make([]string, 0, len(typed))
|
result := make([]string, 0, len(typed))
|
||||||
for _, item := range typed {
|
for _, item := range typed {
|
||||||
if str, ok := item.(string); ok {
|
if str, ok := item.(string); ok {
|
||||||
@@ -2996,7 +2991,7 @@ func readStringSliceMetadata(metadata map[string]interface{}, key string) []stri
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func readMetadataValueOrNil(metadata map[string]interface{}, key string) interface{} {
|
func readMetadataValueOrNil(metadata map[string]any, key string) any {
|
||||||
if metadata == nil {
|
if metadata == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -3007,9 +3002,9 @@ func readMetadataValueOrNil(metadata map[string]interface{}, key string) interfa
|
|||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeBackchannelLogoutMetadata(metadata map[string]interface{}, logoutURI string, sessionRequired bool) (map[string]interface{}, error) {
|
func normalizeBackchannelLogoutMetadata(metadata map[string]any, logoutURI string, sessionRequired bool) (map[string]any, error) {
|
||||||
if metadata == nil {
|
if metadata == nil {
|
||||||
metadata = map[string]interface{}{}
|
metadata = map[string]any{}
|
||||||
}
|
}
|
||||||
|
|
||||||
trimmedURI := strings.TrimSpace(logoutURI)
|
trimmedURI := strings.TrimSpace(logoutURI)
|
||||||
@@ -3078,7 +3073,7 @@ func isAllowedLocalBackchannelLogoutHost(rawHost string) bool {
|
|||||||
return !strings.Contains(host, ".")
|
return !strings.Contains(host, ".")
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeClientAutoLoginMetadata(metadata map[string]interface{}) (map[string]interface{}, error) {
|
func normalizeClientAutoLoginMetadata(metadata map[string]any) (map[string]any, error) {
|
||||||
if metadata == nil {
|
if metadata == nil {
|
||||||
return metadata, nil
|
return metadata, nil
|
||||||
}
|
}
|
||||||
@@ -3105,11 +3100,11 @@ func normalizeClientAutoLoginMetadata(metadata map[string]interface{}) (map[stri
|
|||||||
func normalizeHeadlessClientConfig(
|
func normalizeHeadlessClientConfig(
|
||||||
tokenAuthMethod string,
|
tokenAuthMethod string,
|
||||||
jwksURI string,
|
jwksURI string,
|
||||||
jwks interface{},
|
jwks any,
|
||||||
metadata map[string]interface{},
|
metadata map[string]any,
|
||||||
) (string, string, interface{}, map[string]interface{}) {
|
) (string, string, any, map[string]any) {
|
||||||
if metadata == nil {
|
if metadata == nil {
|
||||||
metadata = map[string]interface{}{}
|
metadata = map[string]any{}
|
||||||
}
|
}
|
||||||
delete(metadata, domain.MetadataRequestObjectSigningAlg)
|
delete(metadata, domain.MetadataRequestObjectSigningAlg)
|
||||||
|
|
||||||
@@ -3145,7 +3140,7 @@ func normalizeHeadlessClientConfig(
|
|||||||
return tokenAuthMethod, jwksURI, jwks, metadata
|
return tokenAuthMethod, jwksURI, jwks, metadata
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateHeadlessClientInput(jwksURI string, jwks interface{}, metadata map[string]interface{}) error {
|
func validateHeadlessClientInput(jwksURI string, jwks any, metadata map[string]any) error {
|
||||||
if !readMetadataBoolValue(metadata, domain.MetadataHeadlessLoginEnabled) {
|
if !readMetadataBoolValue(metadata, domain.MetadataHeadlessLoginEnabled) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -3164,14 +3159,14 @@ func validateHeadlessClientInput(jwksURI string, jwks interface{}, metadata map[
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeClientTypeForHeadless(clientType string, metadata map[string]interface{}) string {
|
func normalizeClientTypeForHeadless(clientType string, metadata map[string]any) string {
|
||||||
if readMetadataBoolValue(metadata, domain.MetadataHeadlessLoginEnabled) {
|
if readMetadataBoolValue(metadata, domain.MetadataHeadlessLoginEnabled) {
|
||||||
return "private"
|
return "private"
|
||||||
}
|
}
|
||||||
return clientType
|
return clientType
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeIDTokenClaimsMetadata(metadata map[string]interface{}) (map[string]interface{}, error) {
|
func normalizeIDTokenClaimsMetadata(metadata map[string]any) (map[string]any, error) {
|
||||||
if metadata == nil {
|
if metadata == nil {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
@@ -3189,16 +3184,16 @@ func normalizeIDTokenClaimsMetadata(metadata map[string]interface{}) (map[string
|
|||||||
return metadata, nil
|
return metadata, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeIDTokenClaims(rawClaims interface{}) ([]normalizedIDTokenClaim, error) {
|
func normalizeIDTokenClaims(rawClaims any) ([]normalizedIDTokenClaim, error) {
|
||||||
rawList, ok := rawClaims.([]interface{})
|
rawList, ok := rawClaims.([]any)
|
||||||
if !ok {
|
if !ok {
|
||||||
if typedList, ok := rawClaims.([]map[string]interface{}); ok {
|
if typedList, ok := rawClaims.([]map[string]any); ok {
|
||||||
rawList = make([]interface{}, 0, len(typedList))
|
rawList = make([]any, 0, len(typedList))
|
||||||
for _, item := range typedList {
|
for _, item := range typedList {
|
||||||
rawList = append(rawList, item)
|
rawList = append(rawList, item)
|
||||||
}
|
}
|
||||||
} else if typedList, ok := rawClaims.([]map[string]any); ok {
|
} else if typedList, ok := rawClaims.([]map[string]any); ok {
|
||||||
rawList = make([]interface{}, 0, len(typedList))
|
rawList = make([]any, 0, len(typedList))
|
||||||
for _, item := range typedList {
|
for _, item := range typedList {
|
||||||
rawList = append(rawList, item)
|
rawList = append(rawList, item)
|
||||||
}
|
}
|
||||||
@@ -3211,13 +3206,11 @@ func normalizeIDTokenClaims(rawClaims interface{}) ([]normalizedIDTokenClaim, er
|
|||||||
seen := make(map[string]struct{}, len(rawList))
|
seen := make(map[string]struct{}, len(rawList))
|
||||||
|
|
||||||
for _, item := range rawList {
|
for _, item := range rawList {
|
||||||
record, ok := item.(map[string]interface{})
|
record, ok := item.(map[string]any)
|
||||||
if !ok {
|
if !ok {
|
||||||
if typedRecord, ok := item.(map[string]any); ok {
|
if typedRecord, ok := item.(map[string]any); ok {
|
||||||
record = make(map[string]interface{}, len(typedRecord))
|
record = make(map[string]any, len(typedRecord))
|
||||||
for key, value := range typedRecord {
|
maps.Copy(record, typedRecord)
|
||||||
record[key] = value
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
return nil, errors.New("metadata.id_token_claims items must be objects")
|
return nil, errors.New("metadata.id_token_claims items must be objects")
|
||||||
}
|
}
|
||||||
@@ -3271,7 +3264,7 @@ func normalizeIDTokenClaims(rawClaims interface{}) ([]normalizedIDTokenClaim, er
|
|||||||
return normalized, nil
|
return normalized, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func readInterfaceString(value interface{}, fallback string) string {
|
func readInterfaceString(value any, fallback string) string {
|
||||||
if value == nil {
|
if value == nil {
|
||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
@@ -3373,8 +3366,9 @@ func valueOr(ptr *string, fallback string) string {
|
|||||||
return *ptr
|
return *ptr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//go:fix inline
|
||||||
func boolPtr(value bool) *bool {
|
func boolPtr(value bool) *bool {
|
||||||
return &value
|
return new(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
func valueOrBool(ptr *bool, fallback bool) bool {
|
func valueOrBool(ptr *bool, fallback bool) bool {
|
||||||
@@ -3398,17 +3392,13 @@ func derefSlice(ptr *[]string, fallback []string) []string {
|
|||||||
return *ptr
|
return *ptr
|
||||||
}
|
}
|
||||||
|
|
||||||
func mergeMetadata(current map[string]interface{}, incoming *map[string]interface{}) map[string]interface{} {
|
func mergeMetadata(current map[string]any, incoming *map[string]any) map[string]any {
|
||||||
if incoming == nil {
|
if incoming == nil {
|
||||||
return current
|
return current
|
||||||
}
|
}
|
||||||
merged := map[string]interface{}{}
|
merged := map[string]any{}
|
||||||
for k, v := range current {
|
maps.Copy(merged, current)
|
||||||
merged[k] = v
|
maps.Copy(merged, *incoming)
|
||||||
}
|
|
||||||
for k, v := range *incoming {
|
|
||||||
merged[k] = v
|
|
||||||
}
|
|
||||||
return merged
|
return merged
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3433,9 +3423,7 @@ func (h *DevHandler) setAuditDetailsExtra(c *fiber.Ctx, extra map[string]any) {
|
|||||||
}
|
}
|
||||||
if existing := c.Locals("audit_details_extra"); existing != nil {
|
if existing := c.Locals("audit_details_extra"); existing != nil {
|
||||||
if m, ok := existing.(map[string]any); ok {
|
if m, ok := existing.(map[string]any); ok {
|
||||||
for k, v := range extra {
|
maps.Copy(m, extra)
|
||||||
m[k] = v
|
|
||||||
}
|
|
||||||
c.Locals("audit_details_extra", m)
|
c.Locals("audit_details_extra", m)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -3494,7 +3482,7 @@ func resolveDevAuditClientID(logItem domain.AuditLog, details map[string]any) st
|
|||||||
return resolvedID
|
return resolvedID
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolveStatusFromMetadata(metadata map[string]interface{}) string {
|
func resolveStatusFromMetadata(metadata map[string]any) string {
|
||||||
if metadata != nil {
|
if metadata != nil {
|
||||||
if value, ok := metadata["status"].(string); ok && strings.ToLower(strings.TrimSpace(value)) == "inactive" {
|
if value, ok := metadata["status"].(string); ok && strings.ToLower(strings.TrimSpace(value)) == "inactive" {
|
||||||
return "inactive"
|
return "inactive"
|
||||||
|
|||||||
@@ -26,18 +26,18 @@ func TestDevHandler_Isolation(t *testing.T) {
|
|||||||
HTTPClient: &http.Client{
|
HTTPClient: &http.Client{
|
||||||
Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
if r.Method == http.MethodGet && r.URL.Path == "/clients" {
|
if r.Method == http.MethodGet && r.URL.Path == "/clients" {
|
||||||
return httpJSONAny(r, http.StatusOK, []map[string]interface{}{
|
return httpJSONAny(r, http.StatusOK, []map[string]any{
|
||||||
{
|
{
|
||||||
"client_id": "client-tenant-a",
|
"client_id": "client-tenant-a",
|
||||||
"client_name": "App Tenant A",
|
"client_name": "App Tenant A",
|
||||||
"token_endpoint_auth_method": "none", // PKCE
|
"token_endpoint_auth_method": "none", // PKCE
|
||||||
"metadata": map[string]interface{}{"tenant_id": "tenant-a"},
|
"metadata": map[string]any{"tenant_id": "tenant-a"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"client_id": "client-tenant-b",
|
"client_id": "client-tenant-b",
|
||||||
"client_name": "App Tenant B",
|
"client_name": "App Tenant B",
|
||||||
"token_endpoint_auth_method": "none", // PKCE
|
"token_endpoint_auth_method": "none", // PKCE
|
||||||
"metadata": map[string]interface{}{"tenant_id": "tenant-b"},
|
"metadata": map[string]any{"tenant_id": "tenant-b"},
|
||||||
},
|
},
|
||||||
}), nil
|
}), nil
|
||||||
}
|
}
|
||||||
@@ -47,15 +47,15 @@ func TestDevHandler_Isolation(t *testing.T) {
|
|||||||
if id == "client-tenant-b" {
|
if id == "client-tenant-b" {
|
||||||
tenantID = "tenant-b"
|
tenantID = "tenant-b"
|
||||||
}
|
}
|
||||||
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
|
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||||
"client_id": id,
|
"client_id": id,
|
||||||
"client_name": "App " + id,
|
"client_name": "App " + id,
|
||||||
"token_endpoint_auth_method": "none",
|
"token_endpoint_auth_method": "none",
|
||||||
"metadata": map[string]interface{}{"tenant_id": tenantID},
|
"metadata": map[string]any{"tenant_id": tenantID},
|
||||||
}), nil
|
}), nil
|
||||||
}
|
}
|
||||||
if r.Method == http.MethodPost && r.URL.Path == "/clients" {
|
if r.Method == http.MethodPost && r.URL.Path == "/clients" {
|
||||||
var body map[string]interface{}
|
var body map[string]any
|
||||||
json.NewDecoder(r.Body).Decode(&body)
|
json.NewDecoder(r.Body).Decode(&body)
|
||||||
return httpJSONAny(r, http.StatusCreated, body), nil
|
return httpJSONAny(r, http.StatusCreated, body), nil
|
||||||
}
|
}
|
||||||
@@ -205,7 +205,7 @@ func TestDevHandler_Isolation(t *testing.T) {
|
|||||||
})
|
})
|
||||||
app.Put("/api/v1/dev/clients/:id", h.UpdateClient)
|
app.Put("/api/v1/dev/clients/:id", h.UpdateClient)
|
||||||
|
|
||||||
body, _ := json.Marshal(map[string]interface{}{
|
body, _ := json.Marshal(map[string]any{
|
||||||
"client_name": "Updated Name",
|
"client_name": "Updated Name",
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -235,7 +235,7 @@ func TestDevHandler_Isolation(t *testing.T) {
|
|||||||
})
|
})
|
||||||
app.Post("/api/v1/dev/clients", h.CreateClient)
|
app.Post("/api/v1/dev/clients", h.CreateClient)
|
||||||
|
|
||||||
body, _ := json.Marshal(map[string]interface{}{
|
body, _ := json.Marshal(map[string]any{
|
||||||
"client_name": "New App",
|
"client_name": "New App",
|
||||||
"type": "pkce",
|
"type": "pkce",
|
||||||
"redirectUris": []string{"http://localhost/cb"},
|
"redirectUris": []string{"http://localhost/cb"},
|
||||||
|
|||||||
@@ -35,10 +35,10 @@ func (m *devMockRPUserMetadataRepo) Upsert(ctx context.Context, metadata *domain
|
|||||||
func TestDevHandler_RPUserMetadataRoundTrip(t *testing.T) {
|
func TestDevHandler_RPUserMetadataRoundTrip(t *testing.T) {
|
||||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
if r.URL.Path == "/clients/client-1" {
|
if r.URL.Path == "/clients/client-1" {
|
||||||
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
|
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||||
"client_id": "client-1",
|
"client_id": "client-1",
|
||||||
"client_name": "Client One",
|
"client_name": "Client One",
|
||||||
"metadata": map[string]interface{}{
|
"metadata": map[string]any{
|
||||||
"tenant_id": "tenant-1",
|
"tenant_id": "tenant-1",
|
||||||
},
|
},
|
||||||
}), nil
|
}), nil
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ func (m *devMockKratosAdmin) GetIdentity(ctx context.Context, identityID string)
|
|||||||
return nil, args.Error(1)
|
return nil, args.Error(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *devMockKratosAdmin) UpdateIdentity(ctx context.Context, identityID string, traits map[string]interface{}, state string) (*service.KratosIdentity, error) {
|
func (m *devMockKratosAdmin) UpdateIdentity(ctx context.Context, identityID string, traits map[string]any, state string) (*service.KratosIdentity, error) {
|
||||||
args := m.Called(ctx, identityID, traits, state)
|
args := m.Called(ctx, identityID, traits, state)
|
||||||
if identity, ok := args.Get(0).(*service.KratosIdentity); ok {
|
if identity, ok := args.Get(0).(*service.KratosIdentity); ok {
|
||||||
return identity, args.Error(1)
|
return identity, args.Error(1)
|
||||||
@@ -292,9 +292,9 @@ func TestGetCurrentProfile_PreservesExistingAuditUserContext(t *testing.T) {
|
|||||||
func TestListClients_Success(t *testing.T) {
|
func TestListClients_Success(t *testing.T) {
|
||||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
if r.URL.Path == "/clients" {
|
if r.URL.Path == "/clients" {
|
||||||
return httpJSONAny(r, http.StatusOK, []map[string]interface{}{
|
return httpJSONAny(r, http.StatusOK, []map[string]any{
|
||||||
{"client_id": "client-1", "client_name": "App One", "metadata": map[string]interface{}{"status": "active"}},
|
{"client_id": "client-1", "client_name": "App One", "metadata": map[string]any{"status": "active"}},
|
||||||
{"client_id": "client-2", "client_name": "App Two", "metadata": map[string]interface{}{"status": "inactive"}},
|
{"client_id": "client-2", "client_name": "App Two", "metadata": map[string]any{"status": "inactive"}},
|
||||||
}), nil
|
}), nil
|
||||||
}
|
}
|
||||||
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
||||||
@@ -326,9 +326,9 @@ func TestListClients_Success(t *testing.T) {
|
|||||||
func TestListClients_UserSeesOnlyClientsAllowedByReBAC(t *testing.T) {
|
func TestListClients_UserSeesOnlyClientsAllowedByReBAC(t *testing.T) {
|
||||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
if r.URL.Path == "/clients" {
|
if r.URL.Path == "/clients" {
|
||||||
return httpJSONAny(r, http.StatusOK, []map[string]interface{}{
|
return httpJSONAny(r, http.StatusOK, []map[string]any{
|
||||||
{"client_id": "client-denied", "client_name": "Denied App", "metadata": map[string]interface{}{"tenant_id": "tenant-a", "status": "active"}},
|
{"client_id": "client-denied", "client_name": "Denied App", "metadata": map[string]any{"tenant_id": "tenant-a", "status": "active"}},
|
||||||
{"client_id": "client-allowed", "client_name": "Allowed App", "metadata": map[string]interface{}{"tenant_id": "tenant-b", "status": "active"}},
|
{"client_id": "client-allowed", "client_name": "Allowed App", "metadata": map[string]any{"tenant_id": "tenant-b", "status": "active"}},
|
||||||
}), nil
|
}), nil
|
||||||
}
|
}
|
||||||
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
||||||
@@ -796,9 +796,9 @@ func TestUpdateClient_AuditDetailsIncludeGeneralSettingChanges(t *testing.T) {
|
|||||||
func TestListClients_ProtectedSystemClientHidden(t *testing.T) {
|
func TestListClients_ProtectedSystemClientHidden(t *testing.T) {
|
||||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
if r.URL.Path == "/clients" {
|
if r.URL.Path == "/clients" {
|
||||||
return httpJSONAny(r, http.StatusOK, []map[string]interface{}{
|
return httpJSONAny(r, http.StatusOK, []map[string]any{
|
||||||
{"client_id": "oathkeeper-introspect", "client_name": "Internal Client"},
|
{"client_id": "oathkeeper-introspect", "client_name": "Internal Client"},
|
||||||
{"client_id": "client-1", "client_name": "App One", "metadata": map[string]interface{}{"status": "active"}},
|
{"client_id": "client-1", "client_name": "App One", "metadata": map[string]any{"status": "active"}},
|
||||||
}), nil
|
}), nil
|
||||||
}
|
}
|
||||||
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
||||||
@@ -834,12 +834,12 @@ func TestListClients_ProtectedSystemClientHidden(t *testing.T) {
|
|||||||
func TestListClients_ReservedSystemNameAliasHidden(t *testing.T) {
|
func TestListClients_ReservedSystemNameAliasHidden(t *testing.T) {
|
||||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
if r.URL.Path == "/clients" {
|
if r.URL.Path == "/clients" {
|
||||||
return httpJSONAny(r, http.StatusOK, []map[string]interface{}{
|
return httpJSONAny(r, http.StatusOK, []map[string]any{
|
||||||
{"client_id": "adminfront", "client_name": "AdminFront", "metadata": map[string]interface{}{"status": "active"}},
|
{"client_id": "adminfront", "client_name": "AdminFront", "metadata": map[string]any{"status": "active"}},
|
||||||
{"client_id": "4f2c9fd6-1111-2222-3333-444444444444", "client_name": "AdminFront", "metadata": map[string]interface{}{"status": "active"}},
|
{"client_id": "4f2c9fd6-1111-2222-3333-444444444444", "client_name": "AdminFront", "metadata": map[string]any{"status": "active"}},
|
||||||
{"client_id": "devfront", "client_name": "DevFront", "metadata": map[string]interface{}{"status": "active"}},
|
{"client_id": "devfront", "client_name": "DevFront", "metadata": map[string]any{"status": "active"}},
|
||||||
{"client_id": "7d2c9fd6-1111-2222-3333-444444444444", "client_name": "DevFront", "metadata": map[string]interface{}{"status": "active"}},
|
{"client_id": "7d2c9fd6-1111-2222-3333-444444444444", "client_name": "DevFront", "metadata": map[string]any{"status": "active"}},
|
||||||
{"client_id": "client-1", "client_name": "App One", "metadata": map[string]interface{}{"status": "active"}},
|
{"client_id": "client-1", "client_name": "App One", "metadata": map[string]any{"status": "active"}},
|
||||||
}), nil
|
}), nil
|
||||||
}
|
}
|
||||||
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
||||||
@@ -878,10 +878,10 @@ func TestListClients_ReservedSystemNameAliasHidden(t *testing.T) {
|
|||||||
func TestGetClient_ReservedSystemNameAliasHidden(t *testing.T) {
|
func TestGetClient_ReservedSystemNameAliasHidden(t *testing.T) {
|
||||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
if r.URL.Path == "/clients/4f2c9fd6-1111-2222-3333-444444444444" {
|
if r.URL.Path == "/clients/4f2c9fd6-1111-2222-3333-444444444444" {
|
||||||
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
|
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||||
"client_id": "4f2c9fd6-1111-2222-3333-444444444444",
|
"client_id": "4f2c9fd6-1111-2222-3333-444444444444",
|
||||||
"client_name": "AdminFront",
|
"client_name": "AdminFront",
|
||||||
"metadata": map[string]interface{}{"status": "active"},
|
"metadata": map[string]any{"status": "active"},
|
||||||
}), nil
|
}), nil
|
||||||
}
|
}
|
||||||
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
||||||
@@ -910,13 +910,13 @@ func TestGetClient_ReservedSystemNameAliasHidden(t *testing.T) {
|
|||||||
func TestUpdateClientStatus_Success(t *testing.T) {
|
func TestUpdateClientStatus_Success(t *testing.T) {
|
||||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" {
|
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" {
|
||||||
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
|
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||||
"client_id": "client-1", "metadata": map[string]interface{}{"status": "active"},
|
"client_id": "client-1", "metadata": map[string]any{"status": "active"},
|
||||||
}), nil
|
}), nil
|
||||||
}
|
}
|
||||||
if r.Method == http.MethodPatch && r.URL.Path == "/clients/client-1" {
|
if r.Method == http.MethodPatch && r.URL.Path == "/clients/client-1" {
|
||||||
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
|
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||||
"client_id": "client-1", "metadata": map[string]interface{}{"status": "inactive"},
|
"client_id": "client-1", "metadata": map[string]any{"status": "inactive"},
|
||||||
}), nil
|
}), nil
|
||||||
}
|
}
|
||||||
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
||||||
@@ -936,7 +936,7 @@ func TestUpdateClientStatus_Success(t *testing.T) {
|
|||||||
})
|
})
|
||||||
app.Patch("/api/v1/dev/clients/:id/status", h.UpdateClientStatus)
|
app.Patch("/api/v1/dev/clients/:id/status", h.UpdateClientStatus)
|
||||||
|
|
||||||
body, _ := json.Marshal(map[string]interface{}{"status": "inactive"})
|
body, _ := json.Marshal(map[string]any{"status": "inactive"})
|
||||||
req := httptest.NewRequest(http.MethodPatch, "/api/v1/dev/clients/client-1/status", bytes.NewReader(body))
|
req := httptest.NewRequest(http.MethodPatch, "/api/v1/dev/clients/client-1/status", bytes.NewReader(body))
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
resp, _ := app.Test(req, -1)
|
resp, _ := app.Test(req, -1)
|
||||||
@@ -950,20 +950,20 @@ func TestUpdateClientStatus_Success(t *testing.T) {
|
|||||||
func TestUpdateClientStatus_UserAllowedByStatusPermission(t *testing.T) {
|
func TestUpdateClientStatus_UserAllowedByStatusPermission(t *testing.T) {
|
||||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" {
|
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" {
|
||||||
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
|
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||||
"client_id": "client-1",
|
"client_id": "client-1",
|
||||||
"client_name": "App One",
|
"client_name": "App One",
|
||||||
"metadata": map[string]interface{}{
|
"metadata": map[string]any{
|
||||||
"tenant_id": "tenant-1",
|
"tenant_id": "tenant-1",
|
||||||
"status": "active",
|
"status": "active",
|
||||||
},
|
},
|
||||||
}), nil
|
}), nil
|
||||||
}
|
}
|
||||||
if r.Method == http.MethodPatch && r.URL.Path == "/clients/client-1" {
|
if r.Method == http.MethodPatch && r.URL.Path == "/clients/client-1" {
|
||||||
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
|
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||||
"client_id": "client-1",
|
"client_id": "client-1",
|
||||||
"client_name": "App One",
|
"client_name": "App One",
|
||||||
"metadata": map[string]interface{}{
|
"metadata": map[string]any{
|
||||||
"tenant_id": "tenant-1",
|
"tenant_id": "tenant-1",
|
||||||
"status": "inactive",
|
"status": "inactive",
|
||||||
},
|
},
|
||||||
@@ -995,7 +995,7 @@ func TestUpdateClientStatus_UserAllowedByStatusPermission(t *testing.T) {
|
|||||||
})
|
})
|
||||||
app.Patch("/api/v1/dev/clients/:id/status", h.UpdateClientStatus)
|
app.Patch("/api/v1/dev/clients/:id/status", h.UpdateClientStatus)
|
||||||
|
|
||||||
body, _ := json.Marshal(map[string]interface{}{"status": "inactive"})
|
body, _ := json.Marshal(map[string]any{"status": "inactive"})
|
||||||
req := httptest.NewRequest(http.MethodPatch, "/api/v1/dev/clients/client-1/status", bytes.NewReader(body))
|
req := httptest.NewRequest(http.MethodPatch, "/api/v1/dev/clients/client-1/status", bytes.NewReader(body))
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
resp, _ := app.Test(req, -1)
|
resp, _ := app.Test(req, -1)
|
||||||
@@ -1010,20 +1010,20 @@ func TestUpdateClientStatus_UserAllowedByStatusPermission(t *testing.T) {
|
|||||||
func TestUpdateClientStatus_UserAllowedByEditConfigPermission(t *testing.T) {
|
func TestUpdateClientStatus_UserAllowedByEditConfigPermission(t *testing.T) {
|
||||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" {
|
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" {
|
||||||
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
|
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||||
"client_id": "client-1",
|
"client_id": "client-1",
|
||||||
"client_name": "App One",
|
"client_name": "App One",
|
||||||
"metadata": map[string]interface{}{
|
"metadata": map[string]any{
|
||||||
"tenant_id": "tenant-1",
|
"tenant_id": "tenant-1",
|
||||||
"status": "active",
|
"status": "active",
|
||||||
},
|
},
|
||||||
}), nil
|
}), nil
|
||||||
}
|
}
|
||||||
if r.Method == http.MethodPatch && r.URL.Path == "/clients/client-1" {
|
if r.Method == http.MethodPatch && r.URL.Path == "/clients/client-1" {
|
||||||
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
|
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||||
"client_id": "client-1",
|
"client_id": "client-1",
|
||||||
"client_name": "App One",
|
"client_name": "App One",
|
||||||
"metadata": map[string]interface{}{
|
"metadata": map[string]any{
|
||||||
"tenant_id": "tenant-1",
|
"tenant_id": "tenant-1",
|
||||||
"status": "inactive",
|
"status": "inactive",
|
||||||
},
|
},
|
||||||
@@ -1055,7 +1055,7 @@ func TestUpdateClientStatus_UserAllowedByEditConfigPermission(t *testing.T) {
|
|||||||
})
|
})
|
||||||
app.Patch("/api/v1/dev/clients/:id/status", h.UpdateClientStatus)
|
app.Patch("/api/v1/dev/clients/:id/status", h.UpdateClientStatus)
|
||||||
|
|
||||||
body, _ := json.Marshal(map[string]interface{}{"status": "inactive"})
|
body, _ := json.Marshal(map[string]any{"status": "inactive"})
|
||||||
req := httptest.NewRequest(http.MethodPatch, "/api/v1/dev/clients/client-1/status", bytes.NewReader(body))
|
req := httptest.NewRequest(http.MethodPatch, "/api/v1/dev/clients/client-1/status", bytes.NewReader(body))
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
resp, _ := app.Test(req, -1)
|
resp, _ := app.Test(req, -1)
|
||||||
@@ -1070,7 +1070,7 @@ func TestUpdateClientStatus_UserAllowedByEditConfigPermission(t *testing.T) {
|
|||||||
func TestUpdateClientStatus_ProtectedSystemClientForbidden(t *testing.T) {
|
func TestUpdateClientStatus_ProtectedSystemClientForbidden(t *testing.T) {
|
||||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
if r.Method == http.MethodGet && r.URL.Path == "/clients/oathkeeper-introspect" {
|
if r.Method == http.MethodGet && r.URL.Path == "/clients/oathkeeper-introspect" {
|
||||||
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
|
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||||
"client_id": "oathkeeper-introspect",
|
"client_id": "oathkeeper-introspect",
|
||||||
}), nil
|
}), nil
|
||||||
}
|
}
|
||||||
@@ -1091,7 +1091,7 @@ func TestUpdateClientStatus_ProtectedSystemClientForbidden(t *testing.T) {
|
|||||||
})
|
})
|
||||||
app.Patch("/api/v1/dev/clients/:id/status", h.UpdateClientStatus)
|
app.Patch("/api/v1/dev/clients/:id/status", h.UpdateClientStatus)
|
||||||
|
|
||||||
body, _ := json.Marshal(map[string]interface{}{"status": "inactive"})
|
body, _ := json.Marshal(map[string]any{"status": "inactive"})
|
||||||
req := httptest.NewRequest(http.MethodPatch, "/api/v1/dev/clients/oathkeeper-introspect/status", bytes.NewReader(body))
|
req := httptest.NewRequest(http.MethodPatch, "/api/v1/dev/clients/oathkeeper-introspect/status", bytes.NewReader(body))
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
resp, _ := app.Test(req, -1)
|
resp, _ := app.Test(req, -1)
|
||||||
@@ -1102,7 +1102,7 @@ func TestUpdateClientStatus_ProtectedSystemClientForbidden(t *testing.T) {
|
|||||||
func TestDeleteClient_Success(t *testing.T) {
|
func TestDeleteClient_Success(t *testing.T) {
|
||||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" {
|
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" {
|
||||||
return httpJSONAny(r, http.StatusOK, map[string]interface{}{"client_id": "client-1"}), nil
|
return httpJSONAny(r, http.StatusOK, map[string]any{"client_id": "client-1"}), nil
|
||||||
}
|
}
|
||||||
if r.Method == http.MethodDelete && r.URL.Path == "/clients/client-1" {
|
if r.Method == http.MethodDelete && r.URL.Path == "/clients/client-1" {
|
||||||
return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody}, nil
|
return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody}, nil
|
||||||
@@ -1142,7 +1142,7 @@ func TestDeleteClient_Success(t *testing.T) {
|
|||||||
func TestDeleteClient_ProtectedSystemClientForbidden(t *testing.T) {
|
func TestDeleteClient_ProtectedSystemClientForbidden(t *testing.T) {
|
||||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
if r.Method == http.MethodGet && r.URL.Path == "/clients/oathkeeper-introspect" {
|
if r.Method == http.MethodGet && r.URL.Path == "/clients/oathkeeper-introspect" {
|
||||||
return httpJSONAny(r, http.StatusOK, map[string]interface{}{"client_id": "oathkeeper-introspect"}), nil
|
return httpJSONAny(r, http.StatusOK, map[string]any{"client_id": "oathkeeper-introspect"}), nil
|
||||||
}
|
}
|
||||||
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
||||||
})
|
})
|
||||||
@@ -1172,7 +1172,7 @@ func TestDeleteClient_ProtectedSystemClientForbidden(t *testing.T) {
|
|||||||
func TestGetClient_ProtectedSystemClientHidden(t *testing.T) {
|
func TestGetClient_ProtectedSystemClientHidden(t *testing.T) {
|
||||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
if r.Method == http.MethodGet && r.URL.Path == "/clients/oathkeeper-introspect" {
|
if r.Method == http.MethodGet && r.URL.Path == "/clients/oathkeeper-introspect" {
|
||||||
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
|
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||||
"client_id": "oathkeeper-introspect",
|
"client_id": "oathkeeper-introspect",
|
||||||
"client_name": "Internal Client",
|
"client_name": "Internal Client",
|
||||||
}), nil
|
}), nil
|
||||||
@@ -1203,10 +1203,10 @@ func TestGetClient_ProtectedSystemClientHidden(t *testing.T) {
|
|||||||
func TestGetClient_RPAdminAllowedByKetoViewPermission(t *testing.T) {
|
func TestGetClient_RPAdminAllowedByKetoViewPermission(t *testing.T) {
|
||||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" {
|
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" {
|
||||||
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
|
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||||
"client_id": "client-1",
|
"client_id": "client-1",
|
||||||
"client_name": "App One",
|
"client_name": "App One",
|
||||||
"metadata": map[string]interface{}{
|
"metadata": map[string]any{
|
||||||
"tenant_id": "tenant-b",
|
"tenant_id": "tenant-b",
|
||||||
"status": "active",
|
"status": "active",
|
||||||
},
|
},
|
||||||
@@ -1248,11 +1248,11 @@ func TestGetClient_RPAdminAllowedByKetoViewPermission(t *testing.T) {
|
|||||||
func TestGetClient_RedactsSecretWithoutViewSecretPermission(t *testing.T) {
|
func TestGetClient_RedactsSecretWithoutViewSecretPermission(t *testing.T) {
|
||||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" {
|
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" {
|
||||||
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
|
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||||
"client_id": "client-1",
|
"client_id": "client-1",
|
||||||
"client_name": "App One",
|
"client_name": "App One",
|
||||||
"client_secret": "stored-secret",
|
"client_secret": "stored-secret",
|
||||||
"metadata": map[string]interface{}{
|
"metadata": map[string]any{
|
||||||
"tenant_id": "tenant-1",
|
"tenant_id": "tenant-1",
|
||||||
"status": "active",
|
"status": "active",
|
||||||
},
|
},
|
||||||
@@ -1297,11 +1297,11 @@ func TestGetClient_RedactsSecretWithoutViewSecretPermission(t *testing.T) {
|
|||||||
func TestGetClient_UserAllowedToViewSecretByPermission(t *testing.T) {
|
func TestGetClient_UserAllowedToViewSecretByPermission(t *testing.T) {
|
||||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" {
|
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" {
|
||||||
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
|
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||||
"client_id": "client-1",
|
"client_id": "client-1",
|
||||||
"client_name": "App One",
|
"client_name": "App One",
|
||||||
"client_secret": "stored-secret",
|
"client_secret": "stored-secret",
|
||||||
"metadata": map[string]interface{}{
|
"metadata": map[string]any{
|
||||||
"tenant_id": "tenant-1",
|
"tenant_id": "tenant-1",
|
||||||
"status": "active",
|
"status": "active",
|
||||||
},
|
},
|
||||||
@@ -1346,10 +1346,10 @@ func TestGetClient_UserAllowedToViewSecretByPermission(t *testing.T) {
|
|||||||
func TestRotateClientSecret_Success(t *testing.T) {
|
func TestRotateClientSecret_Success(t *testing.T) {
|
||||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" {
|
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" {
|
||||||
return httpJSONAny(r, http.StatusOK, map[string]interface{}{"client_id": "client-1"}), nil
|
return httpJSONAny(r, http.StatusOK, map[string]any{"client_id": "client-1"}), nil
|
||||||
}
|
}
|
||||||
if r.Method == http.MethodPut && r.URL.Path == "/clients/client-1" {
|
if r.Method == http.MethodPut && r.URL.Path == "/clients/client-1" {
|
||||||
var body map[string]interface{}
|
var body map[string]any
|
||||||
json.NewDecoder(r.Body).Decode(&body)
|
json.NewDecoder(r.Body).Decode(&body)
|
||||||
return httpJSONAny(r, http.StatusOK, body), nil
|
return httpJSONAny(r, http.StatusOK, body), nil
|
||||||
}
|
}
|
||||||
@@ -1390,7 +1390,7 @@ func TestRotateClientSecret_Success(t *testing.T) {
|
|||||||
func TestCreateClient_RPAdminAllowedByTenantGrantPermission(t *testing.T) {
|
func TestCreateClient_RPAdminAllowedByTenantGrantPermission(t *testing.T) {
|
||||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
if r.Method == http.MethodPost && r.URL.Path == "/clients" {
|
if r.Method == http.MethodPost && r.URL.Path == "/clients" {
|
||||||
var body map[string]interface{}
|
var body map[string]any
|
||||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||||
body["client_secret"] = "generated-secret"
|
body["client_secret"] = "generated-secret"
|
||||||
return httpJSONAny(r, http.StatusCreated, body), nil
|
return httpJSONAny(r, http.StatusCreated, body), nil
|
||||||
@@ -1443,7 +1443,7 @@ func TestCreateClient_RPAdminAllowedByTenantGrantPermission(t *testing.T) {
|
|||||||
func TestCreateClient_ApprovedDeveloperCanCreatePrivateClient(t *testing.T) {
|
func TestCreateClient_ApprovedDeveloperCanCreatePrivateClient(t *testing.T) {
|
||||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
if r.Method == http.MethodPost && r.URL.Path == "/clients" {
|
if r.Method == http.MethodPost && r.URL.Path == "/clients" {
|
||||||
var body map[string]interface{}
|
var body map[string]any
|
||||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||||
body["client_secret"] = "generated-secret"
|
body["client_secret"] = "generated-secret"
|
||||||
return httpJSONAny(r, http.StatusCreated, body), nil
|
return httpJSONAny(r, http.StatusCreated, body), nil
|
||||||
@@ -1625,11 +1625,11 @@ func TestRevokeDeveloperGrantRelation_DeletesRequiredTenantRelations(t *testing.
|
|||||||
func TestGetStats_Success(t *testing.T) {
|
func TestGetStats_Success(t *testing.T) {
|
||||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
if r.URL.Path == "/clients" {
|
if r.URL.Path == "/clients" {
|
||||||
return httpJSONAny(r, http.StatusOK, []map[string]interface{}{
|
return httpJSONAny(r, http.StatusOK, []map[string]any{
|
||||||
{"client_id": "c1", "metadata": map[string]interface{}{"tenant_id": "t1"}},
|
{"client_id": "c1", "metadata": map[string]any{"tenant_id": "t1"}},
|
||||||
{"client_id": "c2", "metadata": map[string]interface{}{"tenant_id": "t1"}},
|
{"client_id": "c2", "metadata": map[string]any{"tenant_id": "t1"}},
|
||||||
{"client_id": "oathkeeper-introspect", "metadata": map[string]interface{}{"tenant_id": "t1"}},
|
{"client_id": "oathkeeper-introspect", "metadata": map[string]any{"tenant_id": "t1"}},
|
||||||
{"client_id": "c3", "metadata": map[string]interface{}{"tenant_id": "t2"}},
|
{"client_id": "c3", "metadata": map[string]any{"tenant_id": "t2"}},
|
||||||
}), nil
|
}), nil
|
||||||
}
|
}
|
||||||
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
||||||
@@ -1693,9 +1693,9 @@ func TestGetStats_UserScopesAuditMetricsToVisibleClients(t *testing.T) {
|
|||||||
now := time.Now()
|
now := time.Now()
|
||||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
if r.URL.Path == "/clients" {
|
if r.URL.Path == "/clients" {
|
||||||
return httpJSONAny(r, http.StatusOK, []map[string]interface{}{
|
return httpJSONAny(r, http.StatusOK, []map[string]any{
|
||||||
{"client_id": "client-owned", "metadata": map[string]interface{}{"tenant_id": "tenant-a"}},
|
{"client_id": "client-owned", "metadata": map[string]any{"tenant_id": "tenant-a"}},
|
||||||
{"client_id": "client-other", "metadata": map[string]interface{}{"tenant_id": "tenant-a"}},
|
{"client_id": "client-other", "metadata": map[string]any{"tenant_id": "tenant-a"}},
|
||||||
}), nil
|
}), nil
|
||||||
}
|
}
|
||||||
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
||||||
@@ -1785,9 +1785,9 @@ func TestGetStats_UserScopesAuditMetricsToVisibleClients(t *testing.T) {
|
|||||||
func TestGetRPUsageDaily_UserScopesItemsToVisibleClients(t *testing.T) {
|
func TestGetRPUsageDaily_UserScopesItemsToVisibleClients(t *testing.T) {
|
||||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
if r.URL.Path == "/clients" {
|
if r.URL.Path == "/clients" {
|
||||||
return httpJSONAny(r, http.StatusOK, []map[string]interface{}{
|
return httpJSONAny(r, http.StatusOK, []map[string]any{
|
||||||
{"client_id": "client-owned", "client_name": "Owned App", "metadata": map[string]interface{}{"tenant_id": "tenant-a"}},
|
{"client_id": "client-owned", "client_name": "Owned App", "metadata": map[string]any{"tenant_id": "tenant-a"}},
|
||||||
{"client_id": "client-other", "client_name": "Other App", "metadata": map[string]interface{}{"tenant_id": "tenant-a"}},
|
{"client_id": "client-other", "client_name": "Other App", "metadata": map[string]any{"tenant_id": "tenant-a"}},
|
||||||
}), nil
|
}), nil
|
||||||
}
|
}
|
||||||
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
||||||
@@ -1997,7 +1997,7 @@ func TestCreateClient_DefaultsSkipConsentToTrue(t *testing.T) {
|
|||||||
|
|
||||||
func TestNormalizeClientAutoLoginMetadata(t *testing.T) {
|
func TestNormalizeClientAutoLoginMetadata(t *testing.T) {
|
||||||
t.Run("keeps supported flag and URL", func(t *testing.T) {
|
t.Run("keeps supported flag and URL", func(t *testing.T) {
|
||||||
metadata, err := normalizeClientAutoLoginMetadata(map[string]interface{}{
|
metadata, err := normalizeClientAutoLoginMetadata(map[string]any{
|
||||||
"auto_login_supported": true,
|
"auto_login_supported": true,
|
||||||
"auto_login_url": "https://rp.example.com/login?auto=1",
|
"auto_login_url": "https://rp.example.com/login?auto=1",
|
||||||
})
|
})
|
||||||
@@ -2007,14 +2007,14 @@ func TestNormalizeClientAutoLoginMetadata(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("requires URL when supported", func(t *testing.T) {
|
t.Run("requires URL when supported", func(t *testing.T) {
|
||||||
_, err := normalizeClientAutoLoginMetadata(map[string]interface{}{
|
_, err := normalizeClientAutoLoginMetadata(map[string]any{
|
||||||
"auto_login_supported": true,
|
"auto_login_supported": true,
|
||||||
})
|
})
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("removes URL when unsupported", func(t *testing.T) {
|
t.Run("removes URL when unsupported", func(t *testing.T) {
|
||||||
metadata, err := normalizeClientAutoLoginMetadata(map[string]interface{}{
|
metadata, err := normalizeClientAutoLoginMetadata(map[string]any{
|
||||||
"auto_login_supported": false,
|
"auto_login_supported": false,
|
||||||
"auto_login_url": "https://rp.example.com/login?auto=1",
|
"auto_login_url": "https://rp.example.com/login?auto=1",
|
||||||
})
|
})
|
||||||
@@ -2293,9 +2293,9 @@ func TestCreateClient_NormalizesIDTokenClaimsMetadata(t *testing.T) {
|
|||||||
resp, _ := app.Test(req, -1)
|
resp, _ := app.Test(req, -1)
|
||||||
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||||
|
|
||||||
claims, ok := captured.Metadata[domain.MetadataIDTokenClaims].([]interface{})
|
claims, ok := captured.Metadata[domain.MetadataIDTokenClaims].([]any)
|
||||||
if assert.True(t, ok) && assert.Len(t, claims, 2) {
|
if assert.True(t, ok) && assert.Len(t, claims, 2) {
|
||||||
first, ok := claims[0].(map[string]interface{})
|
first, ok := claims[0].(map[string]any)
|
||||||
if assert.True(t, ok) {
|
if assert.True(t, ok) {
|
||||||
assert.Equal(t, "top_level", first["namespace"])
|
assert.Equal(t, "top_level", first["namespace"])
|
||||||
assert.Equal(t, "locale", first["key"])
|
assert.Equal(t, "locale", first["key"])
|
||||||
@@ -2305,7 +2305,7 @@ func TestCreateClient_NormalizesIDTokenClaimsMetadata(t *testing.T) {
|
|||||||
assert.False(t, hasID)
|
assert.False(t, hasID)
|
||||||
}
|
}
|
||||||
|
|
||||||
second, ok := claims[1].(map[string]interface{})
|
second, ok := claims[1].(map[string]any)
|
||||||
if assert.True(t, ok) {
|
if assert.True(t, ok) {
|
||||||
assert.Equal(t, "rp_claims", second["namespace"])
|
assert.Equal(t, "rp_claims", second["namespace"])
|
||||||
assert.Equal(t, "tier", second["key"])
|
assert.Equal(t, "tier", second["key"])
|
||||||
@@ -2464,7 +2464,7 @@ func TestUpdateClient_AllowsExplicitSkipConsentFalse(t *testing.T) {
|
|||||||
Scope: "openid profile",
|
Scope: "openid profile",
|
||||||
TokenEndpointAuthMethod: "none",
|
TokenEndpointAuthMethod: "none",
|
||||||
SkipConsent: ¤tSkipConsent,
|
SkipConsent: ¤tSkipConsent,
|
||||||
Metadata: map[string]interface{}{"status": "active"},
|
Metadata: map[string]any{"status": "active"},
|
||||||
}), nil
|
}), nil
|
||||||
}
|
}
|
||||||
if r.Method == http.MethodPut && r.URL.Path == "/clients/client-1" {
|
if r.Method == http.MethodPut && r.URL.Path == "/clients/client-1" {
|
||||||
@@ -2620,7 +2620,7 @@ func TestUpdateClient_RevokesExistingConsentsWhenTenantPolicyChanges(t *testing.
|
|||||||
ResponseTypes: []string{"code"},
|
ResponseTypes: []string{"code"},
|
||||||
Scope: "openid tenant profile email",
|
Scope: "openid tenant profile email",
|
||||||
TokenEndpointAuthMethod: "none",
|
TokenEndpointAuthMethod: "none",
|
||||||
Metadata: map[string]interface{}{
|
Metadata: map[string]any{
|
||||||
"tenant_access_restricted": true,
|
"tenant_access_restricted": true,
|
||||||
"allowed_tenants": []string{"tenant-a"},
|
"allowed_tenants": []string{"tenant-a"},
|
||||||
},
|
},
|
||||||
@@ -2704,7 +2704,7 @@ func TestUpdateClient_DoesNotRevokeConsentsWhenTenantPolicyUnchanged(t *testing.
|
|||||||
ResponseTypes: []string{"code"},
|
ResponseTypes: []string{"code"},
|
||||||
Scope: "openid tenant profile email",
|
Scope: "openid tenant profile email",
|
||||||
TokenEndpointAuthMethod: "none",
|
TokenEndpointAuthMethod: "none",
|
||||||
Metadata: map[string]interface{}{
|
Metadata: map[string]any{
|
||||||
"tenant_access_restricted": true,
|
"tenant_access_restricted": true,
|
||||||
"allowed_tenants": []string{"tenant-a"},
|
"allowed_tenants": []string{"tenant-a"},
|
||||||
},
|
},
|
||||||
@@ -2991,9 +2991,9 @@ func TestListAuditLogs_UserAllowedByRPAuditPermission(t *testing.T) {
|
|||||||
}
|
}
|
||||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
if r.URL.Path == "/clients" {
|
if r.URL.Path == "/clients" {
|
||||||
return httpJSONAny(r, http.StatusOK, []map[string]interface{}{
|
return httpJSONAny(r, http.StatusOK, []map[string]any{
|
||||||
{"client_id": "client-allowed", "client_name": "Allowed App", "metadata": map[string]interface{}{"tenant_id": "tenant-a"}},
|
{"client_id": "client-allowed", "client_name": "Allowed App", "metadata": map[string]any{"tenant_id": "tenant-a"}},
|
||||||
{"client_id": "client-denied", "client_name": "Denied App", "metadata": map[string]interface{}{"tenant_id": "tenant-b"}},
|
{"client_id": "client-denied", "client_name": "Denied App", "metadata": map[string]any{"tenant_id": "tenant-b"}},
|
||||||
}), nil
|
}), nil
|
||||||
}
|
}
|
||||||
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
||||||
@@ -3124,7 +3124,7 @@ func TestListClientRelations_RPAdminAllowedByViewRelationshipsPermission(t *test
|
|||||||
mockKratos := new(devMockKratosAdmin)
|
mockKratos := new(devMockKratosAdmin)
|
||||||
mockKratos.On("GetIdentity", mock.Anything, "user-2").Return(&service.KratosIdentity{
|
mockKratos.On("GetIdentity", mock.Anything, "user-2").Return(&service.KratosIdentity{
|
||||||
ID: "user-2",
|
ID: "user-2",
|
||||||
Traits: map[string]interface{}{
|
Traits: map[string]any{
|
||||||
"name": "김용연",
|
"name": "김용연",
|
||||||
"email": "kyy@example.com",
|
"email": "kyy@example.com",
|
||||||
"id": "kyy01",
|
"id": "kyy01",
|
||||||
@@ -3195,7 +3195,7 @@ func TestListClientRelations_DedupesDuplicateRelations(t *testing.T) {
|
|||||||
mockKratos := new(devMockKratosAdmin)
|
mockKratos := new(devMockKratosAdmin)
|
||||||
mockKratos.On("GetIdentity", mock.Anything, "user-1").Return(&service.KratosIdentity{
|
mockKratos.On("GetIdentity", mock.Anything, "user-1").Return(&service.KratosIdentity{
|
||||||
ID: "user-1",
|
ID: "user-1",
|
||||||
Traits: map[string]interface{}{
|
Traits: map[string]any{
|
||||||
"name": "Tester",
|
"name": "Tester",
|
||||||
"email": "tester@example.com",
|
"email": "tester@example.com",
|
||||||
},
|
},
|
||||||
@@ -3371,7 +3371,7 @@ func TestSearchUsers_RPAdminSearchByNameOrEmailWithinTenantScope(t *testing.T) {
|
|||||||
mockKratos.On("ListIdentities", mock.Anything).Return([]service.KratosIdentity{
|
mockKratos.On("ListIdentities", mock.Anything).Return([]service.KratosIdentity{
|
||||||
{
|
{
|
||||||
ID: "user-1",
|
ID: "user-1",
|
||||||
Traits: map[string]interface{}{
|
Traits: map[string]any{
|
||||||
"name": "Alice Kim",
|
"name": "Alice Kim",
|
||||||
"email": "alice@example.com",
|
"email": "alice@example.com",
|
||||||
"id": "alice01",
|
"id": "alice01",
|
||||||
@@ -3380,7 +3380,7 @@ func TestSearchUsers_RPAdminSearchByNameOrEmailWithinTenantScope(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
ID: "user-2",
|
ID: "user-2",
|
||||||
Traits: map[string]interface{}{
|
Traits: map[string]any{
|
||||||
"name": "Bob Lee",
|
"name": "Bob Lee",
|
||||||
"email": "bob@example.com",
|
"email": "bob@example.com",
|
||||||
"id": "bob01",
|
"id": "bob01",
|
||||||
@@ -3451,7 +3451,7 @@ func TestSearchUsers_UserAllowedByRPAdminRelation(t *testing.T) {
|
|||||||
mockKratos.On("ListIdentities", mock.Anything).Return([]service.KratosIdentity{
|
mockKratos.On("ListIdentities", mock.Anything).Return([]service.KratosIdentity{
|
||||||
{
|
{
|
||||||
ID: "target-user",
|
ID: "target-user",
|
||||||
Traits: map[string]interface{}{
|
Traits: map[string]any{
|
||||||
"name": "김용연",
|
"name": "김용연",
|
||||||
"email": "kyy@example.com",
|
"email": "kyy@example.com",
|
||||||
"id": "kyy01",
|
"id": "kyy01",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"baron-sso-backend/internal/domain"
|
"baron-sso-backend/internal/domain"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -235,10 +236,8 @@ func nextAvailableHanmacLocalPart(base string, usedLocalParts map[string]bool) s
|
|||||||
}
|
}
|
||||||
|
|
||||||
func appendUniqueString(values []string, value string) []string {
|
func appendUniqueString(values []string, value string) []string {
|
||||||
for _, existing := range values {
|
if slices.Contains(values, value) {
|
||||||
if existing == value {
|
return values
|
||||||
return values
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return append(values, value)
|
return append(values, value)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"maps"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -312,10 +313,7 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
offset = 0
|
offset = 0
|
||||||
} else if offset < len(tenants) {
|
} else if offset < len(tenants) {
|
||||||
end := offset + limit
|
end := min(offset+limit, len(tenants))
|
||||||
if end > len(tenants) {
|
|
||||||
end = len(tenants)
|
|
||||||
}
|
|
||||||
tenants = tenants[offset:end]
|
tenants = tenants[offset:end]
|
||||||
if total > int64(end) && len(tenants) > 0 {
|
if total > int64(end) && len(tenants) > 0 {
|
||||||
last := tenants[len(tenants)-1]
|
last := tenants[len(tenants)-1]
|
||||||
@@ -980,12 +978,8 @@ func mergeTenantCSVRecordConfig(current domain.JSONMap, record tenantCSVRecord)
|
|||||||
}
|
}
|
||||||
|
|
||||||
merged := make(domain.JSONMap, len(current)+len(recordConfig))
|
merged := make(domain.JSONMap, len(current)+len(recordConfig))
|
||||||
for key, value := range current {
|
maps.Copy(merged, current)
|
||||||
merged[key] = value
|
maps.Copy(merged, recordConfig)
|
||||||
}
|
|
||||||
for key, value := range recordConfig {
|
|
||||||
merged[key] = value
|
|
||||||
}
|
|
||||||
return merged, true, nil
|
return merged, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -107,6 +107,10 @@ type MockUserRepoForHandler struct {
|
|||||||
deletedIDs []string
|
deletedIDs []string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *MockUserRepoForHandler) DB() *gorm.DB {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (m *MockUserRepoForHandler) Create(ctx context.Context, user *domain.User) error { return nil }
|
func (m *MockUserRepoForHandler) Create(ctx context.Context, user *domain.User) error { return nil }
|
||||||
func (m *MockUserRepoForHandler) Update(ctx context.Context, user *domain.User) error { return nil }
|
func (m *MockUserRepoForHandler) Update(ctx context.Context, user *domain.User) error { return nil }
|
||||||
func (m *MockUserRepoForHandler) Delete(ctx context.Context, id string) error {
|
func (m *MockUserRepoForHandler) Delete(ctx context.Context, id string) error {
|
||||||
@@ -229,7 +233,7 @@ func TestTenantHandler_CreateTenant(t *testing.T) {
|
|||||||
|
|
||||||
app.Post("/tenants", h.CreateTenant)
|
app.Post("/tenants", h.CreateTenant)
|
||||||
|
|
||||||
input := map[string]interface{}{
|
input := map[string]any{
|
||||||
"name": "Test Tenant",
|
"name": "Test Tenant",
|
||||||
"slug": "test-tenant",
|
"slug": "test-tenant",
|
||||||
"domains": []string{"test.com"},
|
"domains": []string{"test.com"},
|
||||||
@@ -244,7 +248,7 @@ func TestTenantHandler_CreateTenant(t *testing.T) {
|
|||||||
resp, _ := app.Test(req)
|
resp, _ := app.Test(req)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||||
var got map[string]interface{}
|
var got map[string]any
|
||||||
json.NewDecoder(resp.Body).Decode(&got)
|
json.NewDecoder(resp.Body).Decode(&got)
|
||||||
assert.Equal(t, "t1", got["id"])
|
assert.Equal(t, "t1", got["id"])
|
||||||
}
|
}
|
||||||
@@ -1191,7 +1195,7 @@ func TestTenantHandler_ImportTenantsCSVCreatesTenant(t *testing.T) {
|
|||||||
resp, _ := app.Test(req)
|
resp, _ := app.Test(req)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
var got map[string]interface{}
|
var got map[string]any
|
||||||
json.NewDecoder(resp.Body).Decode(&got)
|
json.NewDecoder(resp.Body).Decode(&got)
|
||||||
assert.Equal(t, float64(1), got["created"])
|
assert.Equal(t, float64(1), got["created"])
|
||||||
assert.Equal(t, float64(0), got["updated"])
|
assert.Equal(t, float64(0), got["updated"])
|
||||||
@@ -1246,7 +1250,7 @@ func TestTenantHandler_ImportTenantsCSVResolvesParentSlugToID(t *testing.T) {
|
|||||||
resp, _ := app.Test(req)
|
resp, _ := app.Test(req)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
var got map[string]interface{}
|
var got map[string]any
|
||||||
json.NewDecoder(resp.Body).Decode(&got)
|
json.NewDecoder(resp.Body).Decode(&got)
|
||||||
assert.Equal(t, float64(2), got["created"])
|
assert.Equal(t, float64(2), got["created"])
|
||||||
assert.Equal(t, float64(0), got["failed"])
|
assert.Equal(t, float64(0), got["failed"])
|
||||||
@@ -1292,7 +1296,7 @@ func TestTenantHandler_ImportTenantsCSVDoesNotAssignCreatorAsOrganizationMember(
|
|||||||
resp, _ := app.Test(req)
|
resp, _ := app.Test(req)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
var got map[string]interface{}
|
var got map[string]any
|
||||||
json.NewDecoder(resp.Body).Decode(&got)
|
json.NewDecoder(resp.Body).Decode(&got)
|
||||||
assert.Equal(t, float64(1), got["created"])
|
assert.Equal(t, float64(1), got["created"])
|
||||||
assert.Equal(t, float64(0), got["failed"])
|
assert.Equal(t, float64(0), got["failed"])
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"maps"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"regexp"
|
"regexp"
|
||||||
@@ -201,7 +202,7 @@ func metadataBoolFromMap(metadata map[string]any, keys ...string) (bool, bool) {
|
|||||||
return false, false
|
return false, false
|
||||||
}
|
}
|
||||||
|
|
||||||
func roleFromTraits(traits map[string]interface{}) string {
|
func roleFromTraits(traits map[string]any) string {
|
||||||
if role, ok := domain.NormalizeRoleAlias(extractTraitString(traits, "role")); ok {
|
if role, ok := domain.NormalizeRoleAlias(extractTraitString(traits, "role")); ok {
|
||||||
return role
|
return role
|
||||||
}
|
}
|
||||||
@@ -219,7 +220,7 @@ func normalizeAssignableSystemRole(value string) (string, bool) {
|
|||||||
return role, role == domain.RoleSuperAdmin || role == domain.RoleUser
|
return role, role == domain.RoleSuperAdmin || role == domain.RoleUser
|
||||||
}
|
}
|
||||||
|
|
||||||
func gradeFromTraits(traits map[string]interface{}) string {
|
func gradeFromTraits(traits map[string]any) string {
|
||||||
value := strings.TrimSpace(extractTraitString(traits, "grade"))
|
value := strings.TrimSpace(extractTraitString(traits, "grade"))
|
||||||
if value == "" {
|
if value == "" {
|
||||||
return ""
|
return ""
|
||||||
@@ -265,7 +266,7 @@ func tenantSlugPointerFromRequest(tenantSlug *string, legacyCompanyCode *string)
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func identityTenantAccessKeys(traits map[string]interface{}) []string {
|
func identityTenantAccessKeys(traits map[string]any) []string {
|
||||||
keys := make([]string, 0, 2)
|
keys := make([]string, 0, 2)
|
||||||
if tenantID := strings.ToLower(strings.TrimSpace(extractTraitString(traits, "tenant_id"))); tenantID != "" {
|
if tenantID := strings.ToLower(strings.TrimSpace(extractTraitString(traits, "tenant_id"))); tenantID != "" {
|
||||||
keys = append(keys, tenantID)
|
keys = append(keys, tenantID)
|
||||||
@@ -523,10 +524,7 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
|
|||||||
if offset > len(filtered) {
|
if offset > len(filtered) {
|
||||||
offset = len(filtered)
|
offset = len(filtered)
|
||||||
}
|
}
|
||||||
end := offset + limit
|
end := min(offset+limit, len(filtered))
|
||||||
if end > len(filtered) {
|
|
||||||
end = len(filtered)
|
|
||||||
}
|
|
||||||
pageIdentities = filtered[offset:end]
|
pageIdentities = filtered[offset:end]
|
||||||
if total > int64(end) && len(pageIdentities) > 0 {
|
if total > int64(end) && len(pageIdentities) > 0 {
|
||||||
lastTimestamp, lastID := kratosIdentityCursorKey(pageIdentities[len(pageIdentities)-1])
|
lastTimestamp, lastID := kratosIdentityCursorKey(pageIdentities[len(pageIdentities)-1])
|
||||||
@@ -578,11 +576,32 @@ func (h *UserHandler) GetUser(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
identity, err := h.KratosAdmin.GetIdentity(c.Context(), userID)
|
identity, err := h.KratosAdmin.GetIdentity(c.Context(), userID)
|
||||||
if err != nil {
|
if err != nil || identity == nil {
|
||||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
// [FIX] Support fixed UUID lookup fallback
|
||||||
}
|
id, searchErr := h.KratosAdmin.FindIdentityIDByIdentifier(c.Context(), userID)
|
||||||
if identity == nil {
|
if searchErr == nil && id != "" {
|
||||||
return errorJSON(c, fiber.StatusNotFound, "user not found")
|
identity, err = h.KratosAdmin.GetIdentity(c.Context(), id)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil || identity == nil {
|
||||||
|
// Second Fallback: By Email from local DB
|
||||||
|
if h.UserRepo != nil {
|
||||||
|
local, _ := h.UserRepo.FindByID(c.Context(), userID)
|
||||||
|
if local != nil && local.Email != "" {
|
||||||
|
id, _ = h.KratosAdmin.FindIdentityIDByIdentifier(c.Context(), local.Email)
|
||||||
|
if id != "" {
|
||||||
|
identity, err = h.KratosAdmin.GetIdentity(c.Context(), id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil || identity == nil {
|
||||||
|
if identity == nil {
|
||||||
|
return errorJSON(c, fiber.StatusNotFound, "user not found")
|
||||||
|
}
|
||||||
|
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// [New] Check access scope
|
// [New] Check access scope
|
||||||
@@ -685,7 +704,7 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
|
|||||||
role = normalizedRole
|
role = normalizedRole
|
||||||
}
|
}
|
||||||
|
|
||||||
attributes := map[string]interface{}{
|
attributes := map[string]any{
|
||||||
"department": req.Department,
|
"department": req.Department,
|
||||||
"grade": strings.TrimSpace(req.Grade),
|
"grade": strings.TrimSpace(req.Grade),
|
||||||
"position": req.Position,
|
"position": req.Position,
|
||||||
@@ -775,7 +794,7 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
|
|||||||
if tenantID != "" && h.TenantService != nil {
|
if tenantID != "" && h.TenantService != nil {
|
||||||
tenant, err := h.TenantService.GetTenant(c.Context(), tenantID)
|
tenant, err := h.TenantService.GetTenant(c.Context(), tenantID)
|
||||||
if err == nil && tenant != nil {
|
if err == nil && tenant != nil {
|
||||||
if schema, ok := tenant.Config["userSchema"].([]interface{}); ok {
|
if schema, ok := tenant.Config["userSchema"].([]any); ok {
|
||||||
if err := h.validateMetadata(req.Metadata, schema, true); err != nil {
|
if err := h.validateMetadata(req.Metadata, schema, true); err != nil {
|
||||||
return errorJSON(c, fiber.StatusBadRequest, "metadata validation failed: "+err.Error())
|
return errorJSON(c, fiber.StatusBadRequest, "metadata validation failed: "+err.Error())
|
||||||
}
|
}
|
||||||
@@ -850,6 +869,8 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type bulkUserItem struct {
|
type bulkUserItem struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
UUID string `json:"uuid"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
LoginID string `json:"loginId"`
|
LoginID string `json:"loginId"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -876,6 +897,7 @@ type bulkUserResult struct {
|
|||||||
Success bool `json:"success"`
|
Success bool `json:"success"`
|
||||||
Message string `json:"message,omitempty"`
|
Message string `json:"message,omitempty"`
|
||||||
UserID string `json:"userId,omitempty"`
|
UserID string `json:"userId,omitempty"`
|
||||||
|
ModifiedFields []string `json:"modifiedFields,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
|
func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
|
||||||
@@ -912,7 +934,7 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
|
|||||||
ID string
|
ID string
|
||||||
Slug string
|
Slug string
|
||||||
Name string
|
Name string
|
||||||
Schema []interface{}
|
Schema []any
|
||||||
Groups []domain.UserGroup
|
Groups []domain.UserGroup
|
||||||
LoginIDField string
|
LoginIDField string
|
||||||
}
|
}
|
||||||
@@ -926,7 +948,7 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
|
|||||||
Slug: tenant.Slug,
|
Slug: tenant.Slug,
|
||||||
Name: tenant.Name,
|
Name: tenant.Name,
|
||||||
}
|
}
|
||||||
if s, ok := tenant.Config["userSchema"].([]interface{}); ok {
|
if s, ok := tenant.Config["userSchema"].([]any); ok {
|
||||||
tItem.Schema = s
|
tItem.Schema = s
|
||||||
}
|
}
|
||||||
if lf, ok := tenant.Config["loginIdField"].(string); ok {
|
if lf, ok := tenant.Config["loginIdField"].(string); ok {
|
||||||
@@ -1012,6 +1034,7 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, item := range req.Users {
|
for _, item := range req.Users {
|
||||||
|
var identityID string
|
||||||
email := strings.TrimSpace(item.Email)
|
email := strings.TrimSpace(item.Email)
|
||||||
name := strings.TrimSpace(item.Name)
|
name := strings.TrimSpace(item.Name)
|
||||||
tenantID := strings.TrimSpace(item.TenantID)
|
tenantID := strings.TrimSpace(item.TenantID)
|
||||||
@@ -1200,7 +1223,7 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
|
|||||||
role = "user"
|
role = "user"
|
||||||
}
|
}
|
||||||
|
|
||||||
attributes := map[string]interface{}{
|
attributes := map[string]any{
|
||||||
"department": dept,
|
"department": dept,
|
||||||
"grade": strings.TrimSpace(item.Grade),
|
"grade": strings.TrimSpace(item.Grade),
|
||||||
"position": strings.TrimSpace(item.Position),
|
"position": strings.TrimSpace(item.Position),
|
||||||
@@ -1222,13 +1245,27 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
userPhone := normalizePhoneNumber(item.Phone)
|
userPhone := normalizePhoneNumber(item.Phone)
|
||||||
|
|
||||||
|
// Validate provided UUID if any
|
||||||
|
requestedID := strings.TrimSpace(item.ID)
|
||||||
|
if requestedID == "" {
|
||||||
|
requestedID = strings.TrimSpace(item.UUID)
|
||||||
|
}
|
||||||
|
if requestedID != "" {
|
||||||
|
// Basic UUID format validation
|
||||||
|
matched, _ := regexp.MatchString(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`, strings.ToLower(requestedID))
|
||||||
|
if !matched {
|
||||||
|
results = append(results, bulkUserResult{Email: userEmail, Success: false, Message: "invalid UUID format: " + requestedID})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Validate all collected LoginIDs
|
// Validate all collected LoginIDs
|
||||||
if collectedIDs, ok := attributes["custom_login_ids"].([]string); ok {
|
if collectedIDs, ok := attributes["custom_login_ids"].([]string); ok {
|
||||||
valid := true
|
valid := true
|
||||||
// Collect all emails
|
// Collect all emails
|
||||||
allEmails := []string{userEmail}
|
allEmails := []string{userEmail}
|
||||||
if secondaryRaw, exists := item.Metadata["sub_email"]; exists {
|
if secondaryRaw, exists := item.Metadata["sub_email"]; exists {
|
||||||
if secondaryEmails, ok := secondaryRaw.([]interface{}); ok {
|
if secondaryEmails, ok := secondaryRaw.([]any); ok {
|
||||||
for _, se := range secondaryEmails {
|
for _, se := range secondaryEmails {
|
||||||
if seStr, ok := se.(string); ok {
|
if seStr, ok := se.(string); ok {
|
||||||
allEmails = append(allEmails, seStr)
|
allEmails = append(allEmails, seStr)
|
||||||
@@ -1251,32 +1288,125 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
identityID, err := h.OryProvider.CreateUser(&domain.BrokerUser{
|
resultStatus := "created"
|
||||||
Email: userEmail,
|
// 1. Search-first for Idempotency and Fixed UUID Guarantee
|
||||||
Name: item.Name,
|
if requestedID != "" {
|
||||||
PhoneNumber: userPhone,
|
// Use h.KratosAdmin to search for existing ID or ExternalID
|
||||||
Attributes: attributes,
|
existingID, _ := h.KratosAdmin.FindIdentityIDByIdentifier(c.Context(), requestedID)
|
||||||
}, password)
|
if existingID != "" {
|
||||||
if err != nil {
|
// Verify it's the same user (optional, but safer)
|
||||||
// 만약 이미 존재하는 사용자라면 로컬 DB 및 Keto 관계만 업데이트(Sync)를 시도
|
identityID = existingID
|
||||||
if strings.Contains(err.Error(), "409") || strings.Contains(err.Error(), "already exists") || strings.Contains(err.Error(), "exists already") {
|
resultStatus = "updated"
|
||||||
identityID, err = h.KratosAdmin.FindIdentityIDByIdentifier(c.Context(), userEmail)
|
slog.Info("BulkCreate: Found existing identity by UUID/Identifier", "requestedID", requestedID, "identityID", identityID)
|
||||||
if err != nil || identityID == "" {
|
}
|
||||||
results = append(results, bulkUserResult{Email: userEmail, OriginalEmail: emailEvaluation.OriginalEmail, SuggestedEmail: emailEvaluation.SuggestedEmail, Status: "blockingError", Warnings: emailEvaluation.Warnings, Success: false, Message: "이미 다른 사용자가 해당 식별자(이메일/사번 등)를 사용 중입니다."})
|
}
|
||||||
|
|
||||||
|
if identityID == "" {
|
||||||
|
var err error
|
||||||
|
identityID, err = h.OryProvider.CreateUser(&domain.BrokerUser{
|
||||||
|
ID: requestedID,
|
||||||
|
Email: userEmail,
|
||||||
|
Name: item.Name,
|
||||||
|
PhoneNumber: userPhone,
|
||||||
|
Attributes: attributes,
|
||||||
|
}, password)
|
||||||
|
if err != nil {
|
||||||
|
// 만약 이미 존재하는 사용자라면 로컬 DB 및 Keto 관계만 업데이트(Sync)를 시도
|
||||||
|
if strings.Contains(err.Error(), "409") || strings.Contains(err.Error(), "already exists") || strings.Contains(err.Error(), "exists already") || strings.Contains(err.Error(), "external_id") {
|
||||||
|
// Detect if it's a UUID conflict or Identifier (Email/LoginID) conflict
|
||||||
|
if requestedID != "" && (strings.Contains(err.Error(), requestedID) || strings.Contains(err.Error(), "uuid already exists") || strings.Contains(err.Error(), "external_id")) {
|
||||||
|
// Check if the EXISTING user with this UUID is actually the same person (same email)
|
||||||
|
existingID, lookupErr := h.KratosAdmin.FindIdentityIDByIdentifier(c.Context(), userEmail)
|
||||||
|
if lookupErr == nil && existingID != "" {
|
||||||
|
identityID = existingID
|
||||||
|
resultStatus = "updated"
|
||||||
|
slog.Info("BulkCreate: Conflict detected but same email, reusing identity", "email", userEmail, "identityID", identityID)
|
||||||
|
} else {
|
||||||
|
results = append(results, bulkUserResult{Email: userEmail, OriginalEmail: emailEvaluation.OriginalEmail, SuggestedEmail: emailEvaluation.SuggestedEmail, Status: "blockingError", Warnings: emailEvaluation.Warnings, Success: false, Message: "Conflict: UUID already exists (" + requestedID + ")"})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
identityID, err = h.KratosAdmin.FindIdentityIDByIdentifier(c.Context(), userEmail)
|
||||||
|
if err != nil || identityID == "" {
|
||||||
|
results = append(results, bulkUserResult{Email: userEmail, OriginalEmail: emailEvaluation.OriginalEmail, SuggestedEmail: emailEvaluation.SuggestedEmail, Status: "blockingError", Warnings: emailEvaluation.Warnings, Success: false, Message: "이미 다른 사용자가 해당 식별자(이메일/사번 등)를 사용 중입니다."})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
resultStatus = "updated"
|
||||||
|
slog.Info("BulkCreate: User already exists by identifier, reusing identity", "email", userEmail, "identityID", identityID)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
results = append(results, bulkUserResult{Email: userEmail, OriginalEmail: emailEvaluation.OriginalEmail, SuggestedEmail: emailEvaluation.SuggestedEmail, Status: emailEvaluation.Status, Warnings: emailEvaluation.Warnings, Success: false, Message: err.Error()})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
slog.Info("BulkCreate: User already exists, syncing local DB and Keto", "email", email, "identityID", identityID)
|
|
||||||
} else {
|
} else {
|
||||||
results = append(results, bulkUserResult{Email: userEmail, OriginalEmail: emailEvaluation.OriginalEmail, SuggestedEmail: emailEvaluation.SuggestedEmail, Status: emailEvaluation.Status, Warnings: emailEvaluation.Warnings, Success: false, Message: err.Error()})
|
resultStatus = "created"
|
||||||
continue
|
slog.Info("BulkCreate: New identity created", "email", userEmail, "identityID", identityID)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
slog.Info("BulkCreate: Existing identity found by search-first", "email", userEmail, "identityID", identityID)
|
||||||
|
}
|
||||||
|
|
||||||
|
var modifiedFields []string
|
||||||
|
isRestoration := false
|
||||||
|
if resultStatus == "updated" && identityID != "" {
|
||||||
|
existing, err := h.KratosAdmin.GetIdentity(c.Context(), identityID)
|
||||||
|
if err == nil && existing != nil {
|
||||||
|
// 1. Check for Restoration (Soft-deleted in local DB)
|
||||||
|
if h.UserRepo != nil {
|
||||||
|
var existingLocal domain.User
|
||||||
|
err := h.UserRepo.DB().Unscoped().Where("id = ?", identityID).First(&existingLocal).Error
|
||||||
|
if err == nil {
|
||||||
|
// Check if it was soft-deleted
|
||||||
|
if existingLocal.DeletedAt.Valid {
|
||||||
|
isRestoration = true
|
||||||
|
modifiedFields = append(modifiedFields, "Status")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Compare Traits
|
||||||
|
if name != "" && !strings.EqualFold(extractTraitString(existing.Traits, "name"), name) {
|
||||||
|
modifiedFields = append(modifiedFields, "Name")
|
||||||
|
}
|
||||||
|
if item.Phone != "" && normalizePhoneNumber(extractTraitString(existing.Traits, "phone_number")) != normalizePhoneNumber(item.Phone) {
|
||||||
|
modifiedFields = append(modifiedFields, "Phone")
|
||||||
|
}
|
||||||
|
if dept != "" && !strings.EqualFold(extractTraitString(existing.Traits, "department"), dept) {
|
||||||
|
modifiedFields = append(modifiedFields, "Department")
|
||||||
|
}
|
||||||
|
if item.Grade != "" && !strings.EqualFold(gradeFromTraits(existing.Traits), strings.TrimSpace(item.Grade)) {
|
||||||
|
modifiedFields = append(modifiedFields, "Grade")
|
||||||
|
}
|
||||||
|
if item.Position != "" && !strings.EqualFold(extractTraitString(existing.Traits, "position"), strings.TrimSpace(item.Position)) {
|
||||||
|
modifiedFields = append(modifiedFields, "Position")
|
||||||
|
}
|
||||||
|
if item.JobTitle != "" && !strings.EqualFold(extractTraitString(existing.Traits, "jobTitle"), strings.TrimSpace(item.JobTitle)) {
|
||||||
|
modifiedFields = append(modifiedFields, "JobTitle")
|
||||||
|
}
|
||||||
|
if tItem.ID != "" && extractTraitString(existing.Traits, "tenant_id") != tItem.ID {
|
||||||
|
modifiedFields = append(modifiedFields, "Tenant")
|
||||||
|
}
|
||||||
|
if role != "" && !strings.EqualFold(roleFromTraits(existing.Traits), role) {
|
||||||
|
modifiedFields = append(modifiedFields, "Role")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Finalize Status: If no fields actually changed, it's "unchanged"
|
||||||
|
if len(modifiedFields) == 0 && !isRestoration {
|
||||||
|
resultStatus = "unchanged"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// [CRITICAL FIX] Sync to local DB directly using current data
|
// [CRITICAL FIX] Sync to local DB directly using current data
|
||||||
// Don't fetch from Kratos here as it might have propagation lag
|
// Use the REQUESTED UUID as the primary ID for Baron SSO if provided
|
||||||
|
targetLocalID := identityID
|
||||||
|
if requestedID != "" {
|
||||||
|
targetLocalID = requestedID
|
||||||
|
}
|
||||||
|
|
||||||
if h.UserRepo != nil {
|
if h.UserRepo != nil {
|
||||||
localUser := &domain.User{
|
localUser := &domain.User{
|
||||||
ID: identityID,
|
ID: targetLocalID,
|
||||||
Email: userEmail,
|
Email: userEmail,
|
||||||
Name: name,
|
Name: name,
|
||||||
Phone: normalizePhoneNumber(item.Phone),
|
Phone: normalizePhoneNumber(item.Phone),
|
||||||
@@ -1295,9 +1425,7 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
// Merge metadata
|
// Merge metadata
|
||||||
localUser.Metadata = make(domain.JSONMap)
|
localUser.Metadata = make(domain.JSONMap)
|
||||||
for k, v := range item.Metadata {
|
maps.Copy(localUser.Metadata, item.Metadata)
|
||||||
localUser.Metadata[k] = v
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := h.UserRepo.Update(c.Context(), localUser); err != nil {
|
if err := h.UserRepo.Update(c.Context(), localUser); err != nil {
|
||||||
slog.Error("Failed to sync bulk user to local DB", "email", email, "error", err)
|
slog.Error("Failed to sync bulk user to local DB", "email", email, "error", err)
|
||||||
@@ -1313,9 +1441,30 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
|
|||||||
for i := range loginIDRecords {
|
for i := range loginIDRecords {
|
||||||
loginIDRecords[i].UserID = localUser.ID
|
loginIDRecords[i].UserID = localUser.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// [FIX] Pre-check and cleanup colliding identifiers from OTHER users
|
||||||
|
for _, lid := range loginIDRecords {
|
||||||
|
var existingID domain.UserLoginID
|
||||||
|
// Search in DB (including soft-deleted) for any record with the same login_id but DIFFERENT user_id
|
||||||
|
if err := h.UserRepo.DB().Unscoped().Where("login_id = ? AND user_id != ?", lid.LoginID, localUser.ID).First(&existingID).Error; err == nil {
|
||||||
|
slog.Info("BulkCreate: Cleaning up colliding identifier from another user", "loginID", lid.LoginID, "oldUserID", existingID.UserID)
|
||||||
|
_ = h.UserRepo.DB().Unscoped().Where("login_id = ?", lid.LoginID).Delete(&domain.UserLoginID{}).Error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err := h.UserRepo.UpdateUserLoginIDs(c.Context(), localUser.ID, loginIDRecords); err != nil {
|
if err := h.UserRepo.UpdateUserLoginIDs(c.Context(), localUser.ID, loginIDRecords); err != nil {
|
||||||
slog.Error("Failed to update user login IDs in bulk", "userID", localUser.ID, "error", err)
|
slog.Error("Failed to update user login IDs in bulk", "userID", localUser.ID, "error", err)
|
||||||
markUserProjectionFailed(c.Context(), h.UserProjectionRepo, err)
|
markUserProjectionFailed(c.Context(), h.UserProjectionRepo, err)
|
||||||
|
results = append(results, bulkUserResult{
|
||||||
|
Email: userEmail,
|
||||||
|
OriginalEmail: emailEvaluation.OriginalEmail,
|
||||||
|
SuggestedEmail: emailEvaluation.SuggestedEmail,
|
||||||
|
Status: "blockingError",
|
||||||
|
Warnings: emailEvaluation.Warnings,
|
||||||
|
Success: false,
|
||||||
|
Message: "LoginID sync failed: " + err.Error(),
|
||||||
|
})
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if h.KetoOutboxRepo != nil {
|
if h.KetoOutboxRepo != nil {
|
||||||
@@ -1355,10 +1504,11 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
|
|||||||
Email: userEmail,
|
Email: userEmail,
|
||||||
OriginalEmail: emailEvaluation.OriginalEmail,
|
OriginalEmail: emailEvaluation.OriginalEmail,
|
||||||
SuggestedEmail: emailEvaluation.SuggestedEmail,
|
SuggestedEmail: emailEvaluation.SuggestedEmail,
|
||||||
Status: emailEvaluation.Status,
|
Status: resultStatus,
|
||||||
Warnings: emailEvaluation.Warnings,
|
Warnings: emailEvaluation.Warnings,
|
||||||
Success: true,
|
Success: true,
|
||||||
UserID: identityID,
|
UserID: targetLocalID,
|
||||||
|
ModifiedFields: modifiedFields,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1575,7 +1725,7 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
|
|||||||
// Pre-fetch tenant cache if tenantSlug is being changed.
|
// Pre-fetch tenant cache if tenantSlug is being changed.
|
||||||
type tenantCacheItem struct {
|
type tenantCacheItem struct {
|
||||||
ID string
|
ID string
|
||||||
Schema []interface{}
|
Schema []any
|
||||||
}
|
}
|
||||||
tenantCache := make(map[string]tenantCacheItem)
|
tenantCache := make(map[string]tenantCacheItem)
|
||||||
|
|
||||||
@@ -1913,7 +2063,7 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
|||||||
if h.TenantService != nil {
|
if h.TenantService != nil {
|
||||||
tenant, err := h.TenantService.GetTenant(c.Context(), key)
|
tenant, err := h.TenantService.GetTenant(c.Context(), key)
|
||||||
if err == nil && tenant != nil {
|
if err == nil && tenant != nil {
|
||||||
if schema, ok := tenant.Config["userSchema"].([]interface{}); ok {
|
if schema, ok := tenant.Config["userSchema"].([]any); ok {
|
||||||
if subMeta, ok := val.(map[string]any); ok {
|
if subMeta, ok := val.(map[string]any); ok {
|
||||||
if err := h.validateMetadataWithAuth(subMeta, schema, isAdmin, false); err != nil {
|
if err := h.validateMetadataWithAuth(subMeta, schema, isAdmin, false); err != nil {
|
||||||
return errorJSON(c, fiber.StatusBadRequest, "metadata validation failed for tenant "+tenant.Name+": "+err.Error())
|
return errorJSON(c, fiber.StatusBadRequest, "metadata validation failed for tenant "+tenant.Name+": "+err.Error())
|
||||||
@@ -1934,7 +2084,7 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
|||||||
tenant, _ = h.TenantService.GetTenant(c.Context(), tenantID)
|
tenant, _ = h.TenantService.GetTenant(c.Context(), tenantID)
|
||||||
}
|
}
|
||||||
if tenant != nil {
|
if tenant != nil {
|
||||||
if schema, ok := tenant.Config["userSchema"].([]interface{}); ok {
|
if schema, ok := tenant.Config["userSchema"].([]any); ok {
|
||||||
if err := h.validateMetadataWithAuth(req.Metadata, schema, isAdmin, false); err != nil {
|
if err := h.validateMetadataWithAuth(req.Metadata, schema, isAdmin, false); err != nil {
|
||||||
return errorJSON(c, fiber.StatusBadRequest, "metadata validation failed: "+err.Error())
|
return errorJSON(c, fiber.StatusBadRequest, "metadata validation failed: "+err.Error())
|
||||||
}
|
}
|
||||||
@@ -1946,7 +2096,7 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
traits := identity.Traits
|
traits := identity.Traits
|
||||||
if traits == nil {
|
if traits == nil {
|
||||||
traits = map[string]interface{}{}
|
traits = map[string]any{}
|
||||||
}
|
}
|
||||||
delete(traits, "hanmacFamily")
|
delete(traits, "hanmacFamily")
|
||||||
delete(traits, "userType")
|
delete(traits, "userType")
|
||||||
@@ -2035,10 +2185,8 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
|||||||
if !coreTraits[k] {
|
if !coreTraits[k] {
|
||||||
// Ensure we are merging maps (tenant namespaces) correctly, not overwriting with slices
|
// Ensure we are merging maps (tenant namespaces) correctly, not overwriting with slices
|
||||||
if incomingMap, ok := v.(map[string]any); ok {
|
if incomingMap, ok := v.(map[string]any); ok {
|
||||||
if existingMap, ok := traits[k].(map[string]interface{}); ok {
|
if existingMap, ok := traits[k].(map[string]any); ok {
|
||||||
for subK, subV := range incomingMap {
|
maps.Copy(existingMap, incomingMap)
|
||||||
existingMap[subK] = subV
|
|
||||||
}
|
|
||||||
traits[k] = existingMap
|
traits[k] = existingMap
|
||||||
} else {
|
} else {
|
||||||
traits[k] = incomingMap // New namespace
|
traits[k] = incomingMap // New namespace
|
||||||
@@ -2059,7 +2207,7 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
allEmails := []string{userEmail}
|
allEmails := []string{userEmail}
|
||||||
if secondaryRaw, exists := traits["sub_email"]; exists {
|
if secondaryRaw, exists := traits["sub_email"]; exists {
|
||||||
if secondaryEmails, ok := secondaryRaw.([]interface{}); ok {
|
if secondaryEmails, ok := secondaryRaw.([]any); ok {
|
||||||
for _, se := range secondaryEmails {
|
for _, se := range secondaryEmails {
|
||||||
if seStr, ok := se.(string); ok {
|
if seStr, ok := se.(string); ok {
|
||||||
allEmails = append(allEmails, seStr)
|
allEmails = append(allEmails, seStr)
|
||||||
@@ -2198,6 +2346,8 @@ func (h *UserHandler) DeleteUser(c *fiber.Ctx) error {
|
|||||||
return errorJSON(c, fiber.StatusBadRequest, "user id is required")
|
return errorJSON(c, fiber.StatusBadRequest, "user id is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
slog.Info("[UserHandler] Attempting to delete user", "userID", userID)
|
||||||
|
|
||||||
// [New] Check access scope before deletion
|
// [New] Check access scope before deletion
|
||||||
requester, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
|
requester, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
|
||||||
|
|
||||||
@@ -2228,13 +2378,44 @@ func (h *UserHandler) DeleteUser(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// [FIX] Support fixed UUID deletion
|
||||||
|
// If userID is a fixed UUID, it might not be the Kratos internal ID.
|
||||||
|
actualKratosID := userID
|
||||||
|
if h.KratosAdmin != nil {
|
||||||
|
// 1. Try finding by identifier (which checks external_id if it's a UUID)
|
||||||
|
id, err := h.KratosAdmin.FindIdentityIDByIdentifier(c.Context(), userID)
|
||||||
|
if err == nil && id != "" {
|
||||||
|
actualKratosID = id
|
||||||
|
slog.Info("[UserHandler] Mapped userID to Kratos identity ID", "userID", userID, "actualKratosID", actualKratosID)
|
||||||
|
} else {
|
||||||
|
// 2. Fallback: If not found by ID/ExternalID, try finding by EMAIL from local DB
|
||||||
|
if h.UserRepo != nil {
|
||||||
|
local, err := h.UserRepo.FindByID(c.Context(), userID)
|
||||||
|
if err == nil && local != nil && local.Email != "" {
|
||||||
|
id, err := h.KratosAdmin.FindIdentityIDByIdentifier(c.Context(), local.Email)
|
||||||
|
if err == nil && id != "" {
|
||||||
|
actualKratosID = id
|
||||||
|
slog.Info("[UserHandler] Mapped userID to Kratos identity ID via email", "userID", userID, "email", local.Email, "actualKratosID", actualKratosID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err := h.enqueueDeletedUserRelyingPartyCleanup(c.Context(), requester, userID); err != nil {
|
if err := h.enqueueDeletedUserRelyingPartyCleanup(c.Context(), requester, userID); err != nil {
|
||||||
|
slog.Error("[UserHandler] Failed to enqueue RP cleanup", "userID", userID, "error", err)
|
||||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.KratosAdmin.DeleteIdentity(c.Context(), userID); err != nil {
|
if err := h.KratosAdmin.DeleteIdentity(c.Context(), actualKratosID); err != nil {
|
||||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
slog.Error("[UserHandler] Failed to delete Kratos identity", "userID", userID, "actualKratosID", actualKratosID, "error", err)
|
||||||
|
// If Kratos says 404, it might already be gone, so we can proceed to cleanup local DB
|
||||||
|
if !strings.Contains(err.Error(), "404") {
|
||||||
|
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
slog.Info("[UserHandler] Successfully deleted Kratos identity", "userID", userID, "actualKratosID", actualKratosID)
|
||||||
|
|
||||||
if h.Worksmobile != nil && identity != nil {
|
if h.Worksmobile != nil && identity != nil {
|
||||||
localUser := h.mapToLocalUser(*identity)
|
localUser := h.mapToLocalUser(*identity)
|
||||||
if err := h.Worksmobile.EnqueueUserDeleteIfInScope(c.Context(), *localUser); err != nil {
|
if err := h.Worksmobile.EnqueueUserDeleteIfInScope(c.Context(), *localUser); err != nil {
|
||||||
@@ -2259,6 +2440,8 @@ func (h *UserHandler) DeleteUser(c *fiber.Ctx) error {
|
|||||||
if err := h.UserRepo.Delete(context.Background(), userID); err != nil {
|
if err := h.UserRepo.Delete(context.Background(), userID); err != nil {
|
||||||
slog.Error("[UserHandler] Failed to delete local user read-model", "userID", userID, "error", err)
|
slog.Error("[UserHandler] Failed to delete local user read-model", "userID", userID, "error", err)
|
||||||
markUserProjectionFailed(context.Background(), h.UserProjectionRepo, err)
|
markUserProjectionFailed(context.Background(), h.UserProjectionRepo, err)
|
||||||
|
} else {
|
||||||
|
slog.Info("[UserHandler] Successfully deleted local user read-model", "userID", userID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2387,7 +2570,7 @@ func (h *UserHandler) listDeletedUserRelyingPartyRelations(ctx context.Context,
|
|||||||
var tuples []service.RelationTuple
|
var tuples []service.RelationTuple
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
for attempt := 0; attempt < 3; attempt++ {
|
for attempt := range 3 {
|
||||||
tuples, err = h.KetoService.ListRelations(ctx, "RelyingParty", "", "", subject)
|
tuples, err = h.KetoService.ListRelations(ctx, "RelyingParty", "", "", subject)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -2427,6 +2610,21 @@ func (h *UserHandler) mapIdentitySummary(ctx context.Context, identity service.K
|
|||||||
traits := identity.Traits
|
traits := identity.Traits
|
||||||
role := roleFromTraits(traits)
|
role := roleFromTraits(traits)
|
||||||
|
|
||||||
|
// [FIX] Prioritize Local DB ID (the fixed UUID from user)
|
||||||
|
finalID := identity.ID
|
||||||
|
email := extractTraitString(traits, "email")
|
||||||
|
if h.UserRepo != nil && email != "" {
|
||||||
|
// 1. Try finding by email first as it's a strong identifier
|
||||||
|
if local, err := h.UserRepo.FindByEmail(ctx, email); err == nil && local != nil {
|
||||||
|
finalID = local.ID
|
||||||
|
} else if identity.ExternalID != "" {
|
||||||
|
// 2. Try finding by ID directly (in case ID was fixed to ExternalID)
|
||||||
|
if local, err := h.UserRepo.FindByID(ctx, identity.ExternalID); err == nil && local != nil {
|
||||||
|
finalID = local.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
tenantID := extractTraitString(traits, "tenant_id")
|
tenantID := extractTraitString(traits, "tenant_id")
|
||||||
tenantSlug := ""
|
tenantSlug := ""
|
||||||
var tenantSummary *domain.Tenant
|
var tenantSummary *domain.Tenant
|
||||||
@@ -2440,7 +2638,7 @@ func (h *UserHandler) mapIdentitySummary(ctx context.Context, identity service.K
|
|||||||
|
|
||||||
var customLoginIDs []string
|
var customLoginIDs []string
|
||||||
if raw, ok := traits["custom_login_ids"]; ok {
|
if raw, ok := traits["custom_login_ids"]; ok {
|
||||||
if ids, ok := raw.([]interface{}); ok {
|
if ids, ok := raw.([]any); ok {
|
||||||
for _, id := range ids {
|
for _, id := range ids {
|
||||||
if s, ok := id.(string); ok {
|
if s, ok := id.(string); ok {
|
||||||
customLoginIDs = append(customLoginIDs, s)
|
customLoginIDs = append(customLoginIDs, s)
|
||||||
@@ -2452,7 +2650,7 @@ func (h *UserHandler) mapIdentitySummary(ctx context.Context, identity service.K
|
|||||||
}
|
}
|
||||||
|
|
||||||
summary := userSummary{
|
summary := userSummary{
|
||||||
ID: identity.ID,
|
ID: finalID,
|
||||||
Email: extractTraitString(traits, "email"),
|
Email: extractTraitString(traits, "email"),
|
||||||
LoginID: resolvePasswordLoginID(traits),
|
LoginID: resolvePasswordLoginID(traits),
|
||||||
CustomLoginIDs: customLoginIDs,
|
CustomLoginIDs: customLoginIDs,
|
||||||
@@ -2512,8 +2710,15 @@ func (h *UserHandler) mapToLocalUser(identity service.KratosIdentity) *domain.Us
|
|||||||
traits := identity.Traits
|
traits := identity.Traits
|
||||||
role := roleFromTraits(traits)
|
role := roleFromTraits(traits)
|
||||||
|
|
||||||
|
// [FIX] Prioritize ExternalID as the primary ID for Baron SSO if it exists.
|
||||||
|
// This ensures that admin-provided UUIDs are kept consistent across async syncs.
|
||||||
|
finalID := identity.ID
|
||||||
|
if identity.ExternalID != "" {
|
||||||
|
finalID = identity.ExternalID
|
||||||
|
}
|
||||||
|
|
||||||
user := &domain.User{
|
user := &domain.User{
|
||||||
ID: identity.ID,
|
ID: finalID,
|
||||||
Email: extractTraitString(traits, "email"),
|
Email: extractTraitString(traits, "email"),
|
||||||
Name: extractTraitString(traits, "name"),
|
Name: extractTraitString(traits, "name"),
|
||||||
Phone: extractTraitString(traits, "phone_number"),
|
Phone: extractTraitString(traits, "phone_number"),
|
||||||
@@ -2637,7 +2842,7 @@ func (h *UserHandler) syncKetoRole(ctx context.Context, userID, newRole, oldRole
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func extractTraitString(traits map[string]interface{}, key string) string {
|
func extractTraitString(traits map[string]any, key string) string {
|
||||||
if traits == nil {
|
if traits == nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
@@ -2649,12 +2854,12 @@ func extractTraitString(traits map[string]interface{}, key string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func extractTraitStringArray(traits map[string]interface{}, key string) []string {
|
func extractTraitStringArray(traits map[string]any, key string) []string {
|
||||||
if traits == nil {
|
if traits == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if raw, ok := traits[key]; ok {
|
if raw, ok := traits[key]; ok {
|
||||||
if slice, ok := raw.([]interface{}); ok {
|
if slice, ok := raw.([]any); ok {
|
||||||
var result []string
|
var result []string
|
||||||
for _, v := range slice {
|
for _, v := range slice {
|
||||||
if s, ok := v.(string); ok {
|
if s, ok := v.(string); ok {
|
||||||
@@ -2670,10 +2875,10 @@ func extractTraitStringArray(traits map[string]interface{}, key string) []string
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolvePasswordLoginID(traits map[string]interface{}) string {
|
func resolvePasswordLoginID(traits map[string]any) string {
|
||||||
// First check custom_login_ids (array)
|
// First check custom_login_ids (array)
|
||||||
if raw, ok := traits["custom_login_ids"]; ok {
|
if raw, ok := traits["custom_login_ids"]; ok {
|
||||||
if ids, ok := raw.([]interface{}); ok && len(ids) > 0 {
|
if ids, ok := raw.([]any); ok && len(ids) > 0 {
|
||||||
if first, ok := ids[0].(string); ok {
|
if first, ok := ids[0].(string); ok {
|
||||||
return first
|
return first
|
||||||
}
|
}
|
||||||
@@ -2693,7 +2898,7 @@ func resolvePasswordLoginID(traits map[string]interface{}) string {
|
|||||||
|
|
||||||
// syncCustomLoginIDs collects all fields marked as isLoginId: true from tenant schemas
|
// syncCustomLoginIDs collects all fields marked as isLoginId: true from tenant schemas
|
||||||
// and populates traits["custom_login_ids"] and returns domain.UserLoginID records for DB.
|
// and populates traits["custom_login_ids"] and returns domain.UserLoginID records for DB.
|
||||||
func syncCustomLoginIDs(ctx context.Context, tenantService service.TenantService, traits map[string]interface{}, metadata map[string]any, userID string) []domain.UserLoginID {
|
func syncCustomLoginIDs(ctx context.Context, tenantService service.TenantService, traits map[string]any, metadata map[string]any, userID string) []domain.UserLoginID {
|
||||||
if tenantService == nil {
|
if tenantService == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -2726,13 +2931,13 @@ func syncCustomLoginIDs(ctx context.Context, tenantService service.TenantService
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
schema, ok := tenant.Config["userSchema"].([]interface{})
|
schema, ok := tenant.Config["userSchema"].([]any)
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, fieldRaw := range schema {
|
for _, fieldRaw := range schema {
|
||||||
field, ok := fieldRaw.(map[string]interface{})
|
field, ok := fieldRaw.(map[string]any)
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -2751,7 +2956,7 @@ func syncCustomLoginIDs(ctx context.Context, tenantService service.TenantService
|
|||||||
var val string
|
var val string
|
||||||
if namespaced, ok := metadata[tid].(map[string]any); ok {
|
if namespaced, ok := metadata[tid].(map[string]any); ok {
|
||||||
val, _ = namespaced[fieldKey].(string)
|
val, _ = namespaced[fieldKey].(string)
|
||||||
} else if namespaced, ok := metadata[tid].(map[string]interface{}); ok {
|
} else if namespaced, ok := metadata[tid].(map[string]any); ok {
|
||||||
val, _ = namespaced[fieldKey].(string)
|
val, _ = namespaced[fieldKey].(string)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2761,7 +2966,7 @@ func syncCustomLoginIDs(ctx context.Context, tenantService service.TenantService
|
|||||||
|
|
||||||
if val == "" {
|
if val == "" {
|
||||||
// Check existing trait (namespaced)
|
// Check existing trait (namespaced)
|
||||||
if namespaced, ok := traits[tid].(map[string]interface{}); ok {
|
if namespaced, ok := traits[tid].(map[string]any); ok {
|
||||||
val, _ = namespaced[fieldKey].(string)
|
val, _ = namespaced[fieldKey].(string)
|
||||||
} else if namespaced, ok := traits[tid].(map[string]any); ok {
|
} else if namespaced, ok := traits[tid].(map[string]any); ok {
|
||||||
val, _ = namespaced[fieldKey].(string)
|
val, _ = namespaced[fieldKey].(string)
|
||||||
@@ -2833,13 +3038,13 @@ func isMetadataMap(value any) bool {
|
|||||||
if _, ok := value.(map[string]any); ok {
|
if _, ok := value.(map[string]any); ok {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if _, ok := value.(map[string]interface{}); ok {
|
if _, ok := value.(map[string]any); ok {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeCustomLoginIDsTrait(traits map[string]interface{}) {
|
func normalizeCustomLoginIDsTrait(traits map[string]any) {
|
||||||
raw, exists := traits["custom_login_ids"]
|
raw, exists := traits["custom_login_ids"]
|
||||||
if !exists {
|
if !exists {
|
||||||
return
|
return
|
||||||
@@ -2847,7 +3052,7 @@ func normalizeCustomLoginIDsTrait(traits map[string]interface{}) {
|
|||||||
switch values := raw.(type) {
|
switch values := raw.(type) {
|
||||||
case []string:
|
case []string:
|
||||||
return
|
return
|
||||||
case []interface{}:
|
case []any:
|
||||||
normalized := make([]string, 0, len(values))
|
normalized := make([]string, 0, len(values))
|
||||||
for _, value := range values {
|
for _, value := range values {
|
||||||
if text, ok := value.(string); ok && strings.TrimSpace(text) != "" {
|
if text, ok := value.(string); ok && strings.TrimSpace(text) != "" {
|
||||||
@@ -2909,14 +3114,14 @@ func normalizePhoneNumber(phone string) string {
|
|||||||
return normalized
|
return normalized
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *UserHandler) validateMetadata(metadata map[string]any, schema []interface{}, checkRequired bool) error {
|
func (h *UserHandler) validateMetadata(metadata map[string]any, schema []any, checkRequired bool) error {
|
||||||
return h.validateMetadataWithAuth(metadata, schema, true, checkRequired)
|
return h.validateMetadataWithAuth(metadata, schema, true, checkRequired)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *UserHandler) validateMetadataWithAuth(metadata map[string]any, schema []interface{}, isAdmin bool, checkRequired bool) error {
|
func (h *UserHandler) validateMetadataWithAuth(metadata map[string]any, schema []any, isAdmin bool, checkRequired bool) error {
|
||||||
schemaMap := make(map[string]map[string]interface{})
|
schemaMap := make(map[string]map[string]any)
|
||||||
for _, s := range schema {
|
for _, s := range schema {
|
||||||
if m, ok := s.(map[string]interface{}); ok {
|
if m, ok := s.(map[string]any); ok {
|
||||||
if key, ok := m["key"].(string); ok {
|
if key, ok := m["key"].(string); ok {
|
||||||
schemaMap[key] = m
|
schemaMap[key] = m
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ func (m *MockKratosAdmin) GetIdentity(ctx context.Context, id string) (*service.
|
|||||||
return args.Get(0).(*service.KratosIdentity), args.Error(1)
|
return args.Get(0).(*service.KratosIdentity), args.Error(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockKratosAdmin) UpdateIdentity(ctx context.Context, id string, traits map[string]interface{}, state string) (*service.KratosIdentity, error) {
|
func (m *MockKratosAdmin) UpdateIdentity(ctx context.Context, id string, traits map[string]any, state string) (*service.KratosIdentity, error) {
|
||||||
args := m.Called(ctx, id, traits, state)
|
args := m.Called(ctx, id, traits, state)
|
||||||
if args.Get(0) == nil {
|
if args.Get(0) == nil {
|
||||||
return nil, args.Error(1)
|
return nil, args.Error(1)
|
||||||
@@ -445,8 +445,8 @@ func TestUserHandler_BulkCreateUsers(t *testing.T) {
|
|||||||
ID: "t-123",
|
ID: "t-123",
|
||||||
Slug: "test-tenant",
|
Slug: "test-tenant",
|
||||||
Config: domain.JSONMap{
|
Config: domain.JSONMap{
|
||||||
"userSchema": []interface{}{
|
"userSchema": []any{
|
||||||
map[string]interface{}{"key": "emp_id", "label": "EmpID", "required": true, "isLoginId": true},
|
map[string]any{"key": "emp_id", "label": "EmpID", "required": true, "isLoginId": true},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}, nil).Once()
|
}, nil).Once()
|
||||||
@@ -455,28 +455,32 @@ func TestUserHandler_BulkCreateUsers(t *testing.T) {
|
|||||||
ID: "t-123",
|
ID: "t-123",
|
||||||
Slug: "test-tenant",
|
Slug: "test-tenant",
|
||||||
Config: domain.JSONMap{
|
Config: domain.JSONMap{
|
||||||
"userSchema": []interface{}{
|
"userSchema": []any{
|
||||||
map[string]interface{}{"key": "emp_id", "label": "EmpID", "required": true, "isLoginId": true},
|
map[string]any{"key": "emp_id", "label": "EmpID", "required": true, "isLoginId": true},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}, nil)
|
}, nil)
|
||||||
|
|
||||||
mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil)
|
mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil)
|
||||||
|
|
||||||
|
// [FIX] Search-first diagnostic calls
|
||||||
|
mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, mock.Anything).Return("", nil).Maybe()
|
||||||
|
|
||||||
mockOry.On("CreateUser", mock.Anything, mock.Anything).Return("u-1", nil).Twice()
|
mockOry.On("CreateUser", mock.Anything, mock.Anything).Return("u-1", nil).Twice()
|
||||||
|
|
||||||
payload := map[string]interface{}{
|
payload := map[string]any{
|
||||||
"users": []map[string]interface{}{
|
"users": []map[string]any{
|
||||||
{
|
{
|
||||||
"email": "user1@test.com",
|
"email": "user1@test.com",
|
||||||
"name": "User One",
|
"name": "User One",
|
||||||
"tenantSlug": "test-tenant",
|
"tenantSlug": "test-tenant",
|
||||||
"metadata": map[string]interface{}{"emp_id": "E001"},
|
"metadata": map[string]any{"emp_id": "E001"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"email": "user2@test.com",
|
"email": "user2@test.com",
|
||||||
"name": "User Two",
|
"name": "User Two",
|
||||||
"tenantSlug": "test-tenant",
|
"tenantSlug": "test-tenant",
|
||||||
"metadata": map[string]interface{}{"emp_id": "E002"},
|
"metadata": map[string]any{"emp_id": "E002"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -487,20 +491,20 @@ func TestUserHandler_BulkCreateUsers(t *testing.T) {
|
|||||||
resp, _ := app.Test(req)
|
resp, _ := app.Test(req)
|
||||||
assert.Equal(t, 200, resp.StatusCode)
|
assert.Equal(t, 200, resp.StatusCode)
|
||||||
|
|
||||||
var result map[string]interface{}
|
var result map[string]any
|
||||||
json.NewDecoder(resp.Body).Decode(&result)
|
json.NewDecoder(resp.Body).Decode(&result)
|
||||||
results := result["results"].([]interface{})
|
results := result["results"].([]any)
|
||||||
assert.Len(t, results, 2)
|
assert.Len(t, results, 2)
|
||||||
assert.True(t, results[0].(map[string]interface{})["success"].(bool))
|
assert.True(t, results[0].(map[string]any)["success"].(bool))
|
||||||
assert.True(t, results[1].(map[string]interface{})["success"].(bool))
|
assert.True(t, results[1].(map[string]any)["success"].(bool))
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("Fail - Tenant Not Found", func(t *testing.T) {
|
t.Run("Fail - Tenant Not Found", func(t *testing.T) {
|
||||||
mockTenant.On("GetTenantBySlug", mock.Anything, "wrong-tenant").Return(nil, errors.New("not found")).Once()
|
mockTenant.On("GetTenantBySlug", mock.Anything, "wrong-tenant").Return(nil, errors.New("not found")).Once()
|
||||||
mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil)
|
mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil)
|
||||||
|
|
||||||
payload := map[string]interface{}{
|
payload := map[string]any{
|
||||||
"users": []map[string]interface{}{
|
"users": []map[string]any{
|
||||||
{
|
{
|
||||||
"email": "fail@test.com",
|
"email": "fail@test.com",
|
||||||
"name": "Fail User",
|
"name": "Fail User",
|
||||||
@@ -513,12 +517,12 @@ func TestUserHandler_BulkCreateUsers(t *testing.T) {
|
|||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
resp, _ := app.Test(req)
|
resp, _ := app.Test(req)
|
||||||
var result map[string]interface{}
|
var result map[string]any
|
||||||
json.NewDecoder(resp.Body).Decode(&result)
|
json.NewDecoder(resp.Body).Decode(&result)
|
||||||
results := result["results"].([]interface{})
|
results := result["results"].([]any)
|
||||||
|
|
||||||
assert.False(t, results[0].(map[string]interface{})["success"].(bool))
|
assert.False(t, results[0].(map[string]any)["success"].(bool))
|
||||||
assert.Contains(t, results[0].(map[string]interface{})["message"].(string), "tenant not found")
|
assert.Contains(t, results[0].(map[string]any)["message"].(string), "tenant not found")
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("Fail - Schema Validation (Required)", func(t *testing.T) {
|
t.Run("Fail - Schema Validation (Required)", func(t *testing.T) {
|
||||||
@@ -526,8 +530,8 @@ func TestUserHandler_BulkCreateUsers(t *testing.T) {
|
|||||||
ID: "t-123",
|
ID: "t-123",
|
||||||
Slug: "test-tenant",
|
Slug: "test-tenant",
|
||||||
Config: domain.JSONMap{
|
Config: domain.JSONMap{
|
||||||
"userSchema": []interface{}{
|
"userSchema": []any{
|
||||||
map[string]interface{}{"key": "emp_id", "label": "EmpID", "required": true, "isLoginId": true},
|
map[string]any{"key": "emp_id", "label": "EmpID", "required": true, "isLoginId": true},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}, nil).Once()
|
}, nil).Once()
|
||||||
@@ -536,19 +540,19 @@ func TestUserHandler_BulkCreateUsers(t *testing.T) {
|
|||||||
ID: "t-123",
|
ID: "t-123",
|
||||||
Slug: "test-tenant",
|
Slug: "test-tenant",
|
||||||
Config: domain.JSONMap{
|
Config: domain.JSONMap{
|
||||||
"userSchema": []interface{}{
|
"userSchema": []any{
|
||||||
map[string]interface{}{"key": "emp_id", "label": "EmpID", "required": true, "isLoginId": true},
|
map[string]any{"key": "emp_id", "label": "EmpID", "required": true, "isLoginId": true},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}, nil)
|
}, nil)
|
||||||
|
|
||||||
payload := map[string]interface{}{
|
payload := map[string]any{
|
||||||
"users": []map[string]interface{}{
|
"users": []map[string]any{
|
||||||
{
|
{
|
||||||
"email": "missing-meta@test.com",
|
"email": "missing-meta@test.com",
|
||||||
"name": "No Meta",
|
"name": "No Meta",
|
||||||
"tenantSlug": "test-tenant",
|
"tenantSlug": "test-tenant",
|
||||||
"metadata": map[string]interface{}{}, // emp_id missing
|
"metadata": map[string]any{}, // emp_id missing
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -557,12 +561,12 @@ func TestUserHandler_BulkCreateUsers(t *testing.T) {
|
|||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
resp, _ := app.Test(req)
|
resp, _ := app.Test(req)
|
||||||
var result map[string]interface{}
|
var result map[string]any
|
||||||
json.NewDecoder(resp.Body).Decode(&result)
|
json.NewDecoder(resp.Body).Decode(&result)
|
||||||
results := result["results"].([]interface{})
|
results := result["results"].([]any)
|
||||||
|
|
||||||
assert.False(t, results[0].(map[string]interface{})["success"].(bool))
|
assert.False(t, results[0].(map[string]any)["success"].(bool))
|
||||||
assert.Contains(t, results[0].(map[string]interface{})["message"].(string), "field emp_id is required")
|
assert.Contains(t, results[0].(map[string]any)["message"].(string), "field emp_id is required")
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("Fail - Schema Validation (Regex)", func(t *testing.T) {
|
t.Run("Fail - Schema Validation (Regex)", func(t *testing.T) {
|
||||||
@@ -570,19 +574,19 @@ func TestUserHandler_BulkCreateUsers(t *testing.T) {
|
|||||||
ID: "t-123",
|
ID: "t-123",
|
||||||
Slug: "test-tenant",
|
Slug: "test-tenant",
|
||||||
Config: domain.JSONMap{
|
Config: domain.JSONMap{
|
||||||
"userSchema": []interface{}{
|
"userSchema": []any{
|
||||||
map[string]interface{}{"key": "emp_id", "validation": "^E[0-9]{3}$"},
|
map[string]any{"key": "emp_id", "validation": "^E[0-9]{3}$"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}, nil).Once()
|
}, nil).Once()
|
||||||
|
|
||||||
payload := map[string]interface{}{
|
payload := map[string]any{
|
||||||
"users": []map[string]interface{}{
|
"users": []map[string]any{
|
||||||
{
|
{
|
||||||
"email": "regex-fail@test.com",
|
"email": "regex-fail@test.com",
|
||||||
"name": "Regex Fail",
|
"name": "Regex Fail",
|
||||||
"tenantSlug": "test-tenant",
|
"tenantSlug": "test-tenant",
|
||||||
"metadata": map[string]interface{}{"emp_id": "abc"}, // Should start with E and 3 digits
|
"metadata": map[string]any{"emp_id": "abc"}, // Should start with E and 3 digits
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -591,12 +595,12 @@ func TestUserHandler_BulkCreateUsers(t *testing.T) {
|
|||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
resp, _ := app.Test(req)
|
resp, _ := app.Test(req)
|
||||||
var result map[string]interface{}
|
var result map[string]any
|
||||||
json.NewDecoder(resp.Body).Decode(&result)
|
json.NewDecoder(resp.Body).Decode(&result)
|
||||||
results := result["results"].([]interface{})
|
results := result["results"].([]any)
|
||||||
|
|
||||||
assert.False(t, results[0].(map[string]interface{})["success"].(bool))
|
assert.False(t, results[0].(map[string]any)["success"].(bool))
|
||||||
assert.Contains(t, results[0].(map[string]interface{})["message"].(string), "match validation pattern")
|
assert.Contains(t, results[0].(map[string]any)["message"].(string), "match validation pattern")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -650,20 +654,20 @@ func TestUserHandler_BulkCreateUsers_ResolvesAdditionalAppointment(t *testing.T)
|
|||||||
metadata["employee_id"] == "EMP002"
|
metadata["employee_id"] == "EMP002"
|
||||||
}), mock.Anything).Return("u-appointment", nil).Once()
|
}), mock.Anything).Return("u-appointment", nil).Once()
|
||||||
|
|
||||||
payload := map[string]interface{}{
|
payload := map[string]any{
|
||||||
"users": []map[string]interface{}{
|
"users": []map[string]any{
|
||||||
{
|
{
|
||||||
"email": "dual@test.com",
|
"email": "dual@test.com",
|
||||||
"name": "Dual User",
|
"name": "Dual User",
|
||||||
"tenantSlug": "test-tenant",
|
"tenantSlug": "test-tenant",
|
||||||
"metadata": map[string]interface{}{"employee_id": "EMP001"},
|
"metadata": map[string]any{"employee_id": "EMP001"},
|
||||||
"additionalAppointments": []map[string]interface{}{
|
"additionalAppointments": []map[string]any{
|
||||||
{
|
{
|
||||||
"tenantSlug": "second-tenant",
|
"tenantSlug": "second-tenant",
|
||||||
"department": "센터",
|
"department": "센터",
|
||||||
"grade": "수석",
|
"grade": "수석",
|
||||||
"jobTitle": "Architecture",
|
"jobTitle": "Architecture",
|
||||||
"metadata": map[string]interface{}{"employee_id": "EMP002"},
|
"metadata": map[string]any{"employee_id": "EMP002"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -734,8 +738,8 @@ func TestUserHandler_BulkCreateUsers_AppendsEmailDomainTenantAtLowestPriority(t
|
|||||||
appointment["sourceDomain"] == "samaneng.com"
|
appointment["sourceDomain"] == "samaneng.com"
|
||||||
}), mock.Anything).Return("u-domain-assigned", nil).Once()
|
}), mock.Anything).Return("u-domain-assigned", nil).Once()
|
||||||
|
|
||||||
payload := map[string]interface{}{
|
payload := map[string]any{
|
||||||
"users": []map[string]interface{}{
|
"users": []map[string]any{
|
||||||
{
|
{
|
||||||
"email": "user@samaneng.com",
|
"email": "user@samaneng.com",
|
||||||
"name": "Domain User",
|
"name": "Domain User",
|
||||||
@@ -750,10 +754,10 @@ func TestUserHandler_BulkCreateUsers_AppendsEmailDomainTenantAtLowestPriority(t
|
|||||||
resp, _ := app.Test(req)
|
resp, _ := app.Test(req)
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var result map[string]interface{}
|
var result map[string]any
|
||||||
json.NewDecoder(resp.Body).Decode(&result)
|
json.NewDecoder(resp.Body).Decode(&result)
|
||||||
results := result["results"].([]interface{})
|
results := result["results"].([]any)
|
||||||
assert.True(t, results[0].(map[string]interface{})["success"].(bool))
|
assert.True(t, results[0].(map[string]any)["success"].(bool))
|
||||||
mockTenant.AssertExpectations(t)
|
mockTenant.AssertExpectations(t)
|
||||||
mockOry.AssertExpectations(t)
|
mockOry.AssertExpectations(t)
|
||||||
}
|
}
|
||||||
@@ -785,8 +789,8 @@ func TestUserHandler_BulkCreateUsers_UsesEmailDomainTenantAsPrimaryWhenExplicitT
|
|||||||
user.Attributes["additionalAppointments"] == nil
|
user.Attributes["additionalAppointments"] == nil
|
||||||
}), mock.Anything).Return("u-domain-primary", nil).Once()
|
}), mock.Anything).Return("u-domain-primary", nil).Once()
|
||||||
|
|
||||||
payload := map[string]interface{}{
|
payload := map[string]any{
|
||||||
"users": []map[string]interface{}{
|
"users": []map[string]any{
|
||||||
{
|
{
|
||||||
"email": "user@samaneng.com",
|
"email": "user@samaneng.com",
|
||||||
"name": "Domain Primary User",
|
"name": "Domain Primary User",
|
||||||
@@ -800,10 +804,10 @@ func TestUserHandler_BulkCreateUsers_UsesEmailDomainTenantAsPrimaryWhenExplicitT
|
|||||||
resp, _ := app.Test(req)
|
resp, _ := app.Test(req)
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var result map[string]interface{}
|
var result map[string]any
|
||||||
json.NewDecoder(resp.Body).Decode(&result)
|
json.NewDecoder(resp.Body).Decode(&result)
|
||||||
results := result["results"].([]interface{})
|
results := result["results"].([]any)
|
||||||
assert.True(t, results[0].(map[string]interface{})["success"].(bool))
|
assert.True(t, results[0].(map[string]any)["success"].(bool))
|
||||||
mockTenant.AssertExpectations(t)
|
mockTenant.AssertExpectations(t)
|
||||||
mockOry.AssertExpectations(t)
|
mockOry.AssertExpectations(t)
|
||||||
}
|
}
|
||||||
@@ -853,9 +857,9 @@ func TestUserHandler_ListUsersReturnsNextCursorWhenMoreRowsExist(t *testing.T) {
|
|||||||
app.Get("/users", h.ListUsers)
|
app.Get("/users", h.ListUsers)
|
||||||
|
|
||||||
mockKratos.On("ListIdentities", mock.Anything).Return([]service.KratosIdentity{
|
mockKratos.On("ListIdentities", mock.Anything).Return([]service.KratosIdentity{
|
||||||
{ID: "u-3", State: "active", CreatedAt: createdAt, Traits: map[string]interface{}{"email": "c@example.com", "name": "C"}},
|
{ID: "u-3", State: "active", CreatedAt: createdAt, Traits: map[string]any{"email": "c@example.com", "name": "C"}},
|
||||||
{ID: "u-2", State: "active", CreatedAt: createdAt.Add(-time.Minute), Traits: map[string]interface{}{"email": "b@example.com", "name": "B"}},
|
{ID: "u-2", State: "active", CreatedAt: createdAt.Add(-time.Minute), Traits: map[string]any{"email": "b@example.com", "name": "B"}},
|
||||||
{ID: "u-1", State: "active", CreatedAt: createdAt.Add(-2 * time.Minute), Traits: map[string]interface{}{"email": "a@example.com", "name": "A"}},
|
{ID: "u-1", State: "active", CreatedAt: createdAt.Add(-2 * time.Minute), Traits: map[string]any{"email": "a@example.com", "name": "A"}},
|
||||||
}, nil).Once()
|
}, nil).Once()
|
||||||
|
|
||||||
req := httptest.NewRequest("GET", "/users?limit=2", nil)
|
req := httptest.NewRequest("GET", "/users?limit=2", nil)
|
||||||
@@ -915,8 +919,8 @@ func TestUserHandler_BulkCreateUsers_HanmacEmailPolicy(t *testing.T) {
|
|||||||
return user.Email == "cyhan2@hanmaceng.co.kr"
|
return user.Email == "cyhan2@hanmaceng.co.kr"
|
||||||
}), mock.Anything).Return("u-hanmac", nil).Once()
|
}), mock.Anything).Return("u-hanmac", nil).Once()
|
||||||
|
|
||||||
payload := map[string]interface{}{
|
payload := map[string]any{
|
||||||
"users": []map[string]interface{}{
|
"users": []map[string]any{
|
||||||
{
|
{
|
||||||
"email": "@hanmaceng.co.kr",
|
"email": "@hanmaceng.co.kr",
|
||||||
"name": "한치영",
|
"name": "한치영",
|
||||||
@@ -931,14 +935,14 @@ func TestUserHandler_BulkCreateUsers_HanmacEmailPolicy(t *testing.T) {
|
|||||||
resp, _ := app.Test(req)
|
resp, _ := app.Test(req)
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var result map[string]interface{}
|
var result map[string]any
|
||||||
json.NewDecoder(resp.Body).Decode(&result)
|
json.NewDecoder(resp.Body).Decode(&result)
|
||||||
results := result["results"].([]interface{})
|
results := result["results"].([]any)
|
||||||
row := results[0].(map[string]interface{})
|
row := results[0].(map[string]any)
|
||||||
assert.True(t, row["success"].(bool))
|
assert.True(t, row["success"].(bool))
|
||||||
assert.Equal(t, "cyhan2@hanmaceng.co.kr", row["email"])
|
assert.Equal(t, "cyhan2@hanmaceng.co.kr", row["email"])
|
||||||
assert.Equal(t, "@hanmaceng.co.kr", row["originalEmail"])
|
assert.Equal(t, "@hanmaceng.co.kr", row["originalEmail"])
|
||||||
assert.Contains(t, row["warnings"].([]interface{}), "suggested")
|
assert.Contains(t, row["warnings"].([]any), "suggested")
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("full email duplicate local part is blocking error", func(t *testing.T) {
|
t.Run("full email duplicate local part is blocking error", func(t *testing.T) {
|
||||||
@@ -957,8 +961,8 @@ func TestUserHandler_BulkCreateUsers_HanmacEmailPolicy(t *testing.T) {
|
|||||||
mockRepo.On("FindByCompanyCodes", mock.Anything, []string{"hanmac-family", "hanmac"}).Return([]domain.User{}, nil).Once()
|
mockRepo.On("FindByCompanyCodes", mock.Anything, []string{"hanmac-family", "hanmac"}).Return([]domain.User{}, nil).Once()
|
||||||
mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil).Once()
|
mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil).Once()
|
||||||
|
|
||||||
payload := map[string]interface{}{
|
payload := map[string]any{
|
||||||
"users": []map[string]interface{}{
|
"users": []map[string]any{
|
||||||
{
|
{
|
||||||
"email": "han@samaneng.com",
|
"email": "han@samaneng.com",
|
||||||
"name": "한치영",
|
"name": "한치영",
|
||||||
@@ -973,10 +977,10 @@ func TestUserHandler_BulkCreateUsers_HanmacEmailPolicy(t *testing.T) {
|
|||||||
resp, _ := app.Test(req)
|
resp, _ := app.Test(req)
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var result map[string]interface{}
|
var result map[string]any
|
||||||
json.NewDecoder(resp.Body).Decode(&result)
|
json.NewDecoder(resp.Body).Decode(&result)
|
||||||
results := result["results"].([]interface{})
|
results := result["results"].([]any)
|
||||||
row := results[0].(map[string]interface{})
|
row := results[0].(map[string]any)
|
||||||
assert.False(t, row["success"].(bool))
|
assert.False(t, row["success"].(bool))
|
||||||
assert.Equal(t, "blockingError", row["status"])
|
assert.Equal(t, "blockingError", row["status"])
|
||||||
assert.Contains(t, row["message"].(string), "한맥가족 내에서 이미 사용 중인 이메일 ID입니다.")
|
assert.Contains(t, row["message"].(string), "한맥가족 내에서 이미 사용 중인 이메일 ID입니다.")
|
||||||
@@ -1017,7 +1021,7 @@ func TestUserHandler_CreateUser_HanmacEmailPolicyBlocksDuplicateLocalPart(t *tes
|
|||||||
}, nil).Once()
|
}, nil).Once()
|
||||||
mockRepo.On("FindByCompanyCodes", mock.Anything, []string{"hanmac-family", "hanmac"}).Return([]domain.User{}, nil).Once()
|
mockRepo.On("FindByCompanyCodes", mock.Anything, []string{"hanmac-family", "hanmac"}).Return([]domain.User{}, nil).Once()
|
||||||
|
|
||||||
payload := map[string]interface{}{
|
payload := map[string]any{
|
||||||
"email": "han@samaneng.com",
|
"email": "han@samaneng.com",
|
||||||
"name": "한치영",
|
"name": "한치영",
|
||||||
"tenantSlug": "hanmac",
|
"tenantSlug": "hanmac",
|
||||||
@@ -1029,7 +1033,7 @@ func TestUserHandler_CreateUser_HanmacEmailPolicyBlocksDuplicateLocalPart(t *tes
|
|||||||
resp, _ := app.Test(req)
|
resp, _ := app.Test(req)
|
||||||
assert.Equal(t, http.StatusConflict, resp.StatusCode)
|
assert.Equal(t, http.StatusConflict, resp.StatusCode)
|
||||||
|
|
||||||
var result map[string]interface{}
|
var result map[string]any
|
||||||
json.NewDecoder(resp.Body).Decode(&result)
|
json.NewDecoder(resp.Body).Decode(&result)
|
||||||
assert.Contains(t, result["error"].(string), "한맥가족 내에서 이미 사용 중인 이메일 ID입니다.")
|
assert.Contains(t, result["error"].(string), "한맥가족 내에서 이미 사용 중인 이메일 ID입니다.")
|
||||||
mockOry.AssertNotCalled(t, "CreateUser")
|
mockOry.AssertNotCalled(t, "CreateUser")
|
||||||
@@ -1049,12 +1053,12 @@ func TestUserHandler_BulkUpdateUsers(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("Success - Update Role and Status", func(t *testing.T) {
|
t.Run("Success - Update Role and Status", func(t *testing.T) {
|
||||||
mockKratos.On("GetIdentity", mock.Anything, "u-1").Return(&service.KratosIdentity{
|
mockKratos.On("GetIdentity", mock.Anything, "u-1").Return(&service.KratosIdentity{
|
||||||
ID: "u-1", Traits: map[string]interface{}{"email": "u1@test.com", "tenant_id": "tenant-1"}, State: "active",
|
ID: "u-1", Traits: map[string]any{"email": "u1@test.com", "tenant_id": "tenant-1"}, State: "active",
|
||||||
}, nil).Once()
|
}, nil).Once()
|
||||||
|
|
||||||
mockKratos.On("UpdateIdentity", mock.Anything, "u-1", mock.Anything, "inactive").Return(&service.KratosIdentity{
|
mockKratos.On("UpdateIdentity", mock.Anything, "u-1", mock.Anything, "inactive").Return(&service.KratosIdentity{
|
||||||
ID: "u-1",
|
ID: "u-1",
|
||||||
Traits: map[string]interface{}{
|
Traits: map[string]any{
|
||||||
"email": "u1@test.com",
|
"email": "u1@test.com",
|
||||||
"name": "Bulk User",
|
"name": "Bulk User",
|
||||||
"tenant_id": "tenant-1",
|
"tenant_id": "tenant-1",
|
||||||
@@ -1063,7 +1067,7 @@ func TestUserHandler_BulkUpdateUsers(t *testing.T) {
|
|||||||
}, nil).Once()
|
}, nil).Once()
|
||||||
|
|
||||||
status := "inactive"
|
status := "inactive"
|
||||||
payload := map[string]interface{}{
|
payload := map[string]any{
|
||||||
"userIds": []string{"u-1"},
|
"userIds": []string{"u-1"},
|
||||||
"status": &status,
|
"status": &status,
|
||||||
}
|
}
|
||||||
@@ -1074,10 +1078,10 @@ func TestUserHandler_BulkUpdateUsers(t *testing.T) {
|
|||||||
resp, _ := app.Test(req)
|
resp, _ := app.Test(req)
|
||||||
assert.Equal(t, 200, resp.StatusCode)
|
assert.Equal(t, 200, resp.StatusCode)
|
||||||
|
|
||||||
var result map[string]interface{}
|
var result map[string]any
|
||||||
json.NewDecoder(resp.Body).Decode(&result)
|
json.NewDecoder(resp.Body).Decode(&result)
|
||||||
results := result["results"].([]interface{})
|
results := result["results"].([]any)
|
||||||
assert.True(t, results[0].(map[string]interface{})["success"].(bool))
|
assert.True(t, results[0].(map[string]any)["success"].(bool))
|
||||||
assert.Len(t, worksmobile.upserts, 1)
|
assert.Len(t, worksmobile.upserts, 1)
|
||||||
assert.Equal(t, "u-1", worksmobile.upserts[0].ID)
|
assert.Equal(t, "u-1", worksmobile.upserts[0].ID)
|
||||||
assert.Equal(t, domain.UserStatusPreboarding, worksmobile.upserts[0].Status)
|
assert.Equal(t, domain.UserStatusPreboarding, worksmobile.upserts[0].Status)
|
||||||
@@ -1085,7 +1089,7 @@ func TestUserHandler_BulkUpdateUsers(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("Fail - Super admin cannot assign tenant or RP admin roles", func(t *testing.T) {
|
t.Run("Fail - Super admin cannot assign tenant or RP admin roles", func(t *testing.T) {
|
||||||
for _, role := range []string{domain.RoleTenantAdmin, domain.RoleRPAdmin} {
|
for _, role := range []string{domain.RoleTenantAdmin, domain.RoleRPAdmin} {
|
||||||
payload := map[string]interface{}{
|
payload := map[string]any{
|
||||||
"userIds": []string{"u-1"},
|
"userIds": []string{"u-1"},
|
||||||
"role": role,
|
"role": role,
|
||||||
}
|
}
|
||||||
@@ -1107,7 +1111,7 @@ func TestUserHandler_BulkUpdateUsers(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
role := domain.RoleSuperAdmin
|
role := domain.RoleSuperAdmin
|
||||||
payload := map[string]interface{}{
|
payload := map[string]any{
|
||||||
"userIds": []string{"u-1"},
|
"userIds": []string{"u-1"},
|
||||||
"role": &role,
|
"role": &role,
|
||||||
}
|
}
|
||||||
@@ -1137,7 +1141,7 @@ func TestUserHandler_BulkDeleteUsers(t *testing.T) {
|
|||||||
mockKratos.On("DeleteIdentity", mock.Anything, "u-1").Return(nil).Once()
|
mockKratos.On("DeleteIdentity", mock.Anything, "u-1").Return(nil).Once()
|
||||||
mockKratos.On("DeleteIdentity", mock.Anything, "u-2").Return(nil).Once()
|
mockKratos.On("DeleteIdentity", mock.Anything, "u-2").Return(nil).Once()
|
||||||
|
|
||||||
payload := map[string]interface{}{
|
payload := map[string]any{
|
||||||
"userIds": []string{"u-1", "u-2"},
|
"userIds": []string{"u-1", "u-2"},
|
||||||
}
|
}
|
||||||
body, _ := json.Marshal(payload)
|
body, _ := json.Marshal(payload)
|
||||||
@@ -1182,6 +1186,10 @@ func TestUserHandler_DeleteUserDeletesLocalReadModel(t *testing.T) {
|
|||||||
mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(entry *domain.KetoOutbox) bool {
|
mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(entry *domain.KetoOutbox) bool {
|
||||||
return entry.Namespace == "System" && entry.Object == "global" && entry.Relation == "super_admins" && entry.Subject == "User:u-1" && entry.Action == domain.KetoOutboxActionDelete
|
return entry.Namespace == "System" && entry.Object == "global" && entry.Relation == "super_admins" && entry.Subject == "User:u-1" && entry.Action == domain.KetoOutboxActionDelete
|
||||||
})).Return(nil).Once()
|
})).Return(nil).Once()
|
||||||
|
|
||||||
|
// [FIX] Diagnostic call for fixed UUID mapping
|
||||||
|
mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, "u-1").Return("", nil).Maybe()
|
||||||
|
|
||||||
mockKratos.On("DeleteIdentity", mock.Anything, "u-1").Return(nil).Once()
|
mockKratos.On("DeleteIdentity", mock.Anything, "u-1").Return(nil).Once()
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodDelete, "/users/u-1", nil)
|
req := httptest.NewRequest(http.MethodDelete, "/users/u-1", nil)
|
||||||
@@ -1220,7 +1228,7 @@ func TestUserHandler_BulkDeleteUsers_CleansUpRelyingPartyRelations(t *testing.T)
|
|||||||
})).Return(nil).Once()
|
})).Return(nil).Once()
|
||||||
mockKratos.On("DeleteIdentity", mock.Anything, "u-1").Return(nil).Once()
|
mockKratos.On("DeleteIdentity", mock.Anything, "u-1").Return(nil).Once()
|
||||||
|
|
||||||
payload := map[string]interface{}{
|
payload := map[string]any{
|
||||||
"userIds": []string{"u-1"},
|
"userIds": []string{"u-1"},
|
||||||
}
|
}
|
||||||
body, _ := json.Marshal(payload)
|
body, _ := json.Marshal(payload)
|
||||||
@@ -1281,6 +1289,10 @@ func TestUserHandler_DeleteUserFallsBackToKetoOutboxWhenLiveRelationsAreEmpty(t
|
|||||||
mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(entry *domain.KetoOutbox) bool {
|
mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(entry *domain.KetoOutbox) bool {
|
||||||
return entry.Namespace == "System" && entry.Object == "global" && entry.Relation == "super_admins" && entry.Subject == "User:u-1" && entry.Action == domain.KetoOutboxActionDelete
|
return entry.Namespace == "System" && entry.Object == "global" && entry.Relation == "super_admins" && entry.Subject == "User:u-1" && entry.Action == domain.KetoOutboxActionDelete
|
||||||
})).Return(nil).Once()
|
})).Return(nil).Once()
|
||||||
|
|
||||||
|
// [FIX] Diagnostic call for fixed UUID mapping
|
||||||
|
mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, "u-1").Return("", nil).Maybe()
|
||||||
|
|
||||||
mockKratos.On("DeleteIdentity", mock.Anything, "u-1").Return(nil).Once()
|
mockKratos.On("DeleteIdentity", mock.Anything, "u-1").Return(nil).Once()
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodDelete, "/users/u-1", nil)
|
req := httptest.NewRequest(http.MethodDelete, "/users/u-1", nil)
|
||||||
@@ -1323,6 +1335,10 @@ func TestUserHandler_DeleteUserRecordsCascadeRelyingPartyCleanupAudit(t *testing
|
|||||||
mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(entry *domain.KetoOutbox) bool {
|
mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(entry *domain.KetoOutbox) bool {
|
||||||
return entry.Namespace == "System" && entry.Object == "global" && entry.Relation == "super_admins" && entry.Subject == "User:u-1" && entry.Action == domain.KetoOutboxActionDelete
|
return entry.Namespace == "System" && entry.Object == "global" && entry.Relation == "super_admins" && entry.Subject == "User:u-1" && entry.Action == domain.KetoOutboxActionDelete
|
||||||
})).Return(nil).Once()
|
})).Return(nil).Once()
|
||||||
|
|
||||||
|
// [FIX] Diagnostic call for fixed UUID mapping
|
||||||
|
mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, "u-1").Return("", nil).Maybe()
|
||||||
|
|
||||||
mockKratos.On("DeleteIdentity", mock.Anything, "u-1").Return(nil).Once()
|
mockKratos.On("DeleteIdentity", mock.Anything, "u-1").Return(nil).Once()
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodDelete, "/users/u-1", nil)
|
req := httptest.NewRequest(http.MethodDelete, "/users/u-1", nil)
|
||||||
@@ -1373,21 +1389,21 @@ func TestUserHandler_UpdateUser_AdminOnlyField(t *testing.T) {
|
|||||||
tenantID := "t-123"
|
tenantID := "t-123"
|
||||||
mockKratos.On("GetIdentity", mock.Anything, "u-1").Return(&service.KratosIdentity{
|
mockKratos.On("GetIdentity", mock.Anything, "u-1").Return(&service.KratosIdentity{
|
||||||
ID: "u-1",
|
ID: "u-1",
|
||||||
Traits: map[string]interface{}{"email": "user@test.com", "tenant_id": tenantID},
|
Traits: map[string]any{"email": "user@test.com", "tenant_id": tenantID},
|
||||||
}, nil)
|
}, nil)
|
||||||
|
|
||||||
mockTenant.On("GetTenant", mock.Anything, tenantID).Return(&domain.Tenant{
|
mockTenant.On("GetTenant", mock.Anything, tenantID).Return(&domain.Tenant{
|
||||||
ID: tenantID,
|
ID: tenantID,
|
||||||
Slug: "test-tenant",
|
Slug: "test-tenant",
|
||||||
Config: domain.JSONMap{
|
Config: domain.JSONMap{
|
||||||
"userSchema": []interface{}{
|
"userSchema": []any{
|
||||||
map[string]interface{}{"key": "salary", "adminOnly": true},
|
map[string]any{"key": "salary", "adminOnly": true},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}, nil)
|
}, nil)
|
||||||
|
|
||||||
payload := map[string]interface{}{
|
payload := map[string]any{
|
||||||
"metadata": map[string]interface{}{"salary": 5000},
|
"metadata": map[string]any{"salary": 5000},
|
||||||
}
|
}
|
||||||
body, _ := json.Marshal(payload)
|
body, _ := json.Marshal(payload)
|
||||||
req := httptest.NewRequest("PUT", "/users/u-1", bytes.NewReader(body))
|
req := httptest.NewRequest("PUT", "/users/u-1", bytes.NewReader(body))
|
||||||
@@ -1396,7 +1412,7 @@ func TestUserHandler_UpdateUser_AdminOnlyField(t *testing.T) {
|
|||||||
resp, _ := app.Test(req)
|
resp, _ := app.Test(req)
|
||||||
assert.Equal(t, 400, resp.StatusCode) // validation failed
|
assert.Equal(t, 400, resp.StatusCode) // validation failed
|
||||||
|
|
||||||
var result map[string]interface{}
|
var result map[string]any
|
||||||
json.NewDecoder(resp.Body).Decode(&result)
|
json.NewDecoder(resp.Body).Decode(&result)
|
||||||
assert.Contains(t, result["error"].(string), "field salary is admin only")
|
assert.Contains(t, result["error"].(string), "field salary is admin only")
|
||||||
})
|
})
|
||||||
@@ -1414,11 +1430,11 @@ func TestUserHandler_UpdateUser_RejectsDeprecatedAdminRoles(t *testing.T) {
|
|||||||
for _, role := range []string{domain.RoleTenantAdmin, domain.RoleRPAdmin} {
|
for _, role := range []string{domain.RoleTenantAdmin, domain.RoleRPAdmin} {
|
||||||
mockKratos.On("GetIdentity", mock.Anything, "u-1").Return(&service.KratosIdentity{
|
mockKratos.On("GetIdentity", mock.Anything, "u-1").Return(&service.KratosIdentity{
|
||||||
ID: "u-1",
|
ID: "u-1",
|
||||||
Traits: map[string]interface{}{"email": "user@test.com", "role": domain.RoleUser},
|
Traits: map[string]any{"email": "user@test.com", "role": domain.RoleUser},
|
||||||
State: "active",
|
State: "active",
|
||||||
}, nil).Once()
|
}, nil).Once()
|
||||||
|
|
||||||
payload := map[string]interface{}{"role": role}
|
payload := map[string]any{"role": role}
|
||||||
body, _ := json.Marshal(payload)
|
body, _ := json.Marshal(payload)
|
||||||
req := httptest.NewRequest("PUT", "/users/u-1", bytes.NewReader(body))
|
req := httptest.NewRequest("PUT", "/users/u-1", bytes.NewReader(body))
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
@@ -1437,20 +1453,20 @@ func TestSyncCustomLoginIDs_IgnoresFlatMetadataMaps(t *testing.T) {
|
|||||||
ID: tenantID,
|
ID: tenantID,
|
||||||
Slug: "test-tenant",
|
Slug: "test-tenant",
|
||||||
Config: domain.JSONMap{
|
Config: domain.JSONMap{
|
||||||
"userSchema": []interface{}{
|
"userSchema": []any{
|
||||||
map[string]interface{}{"key": "emp_no", "isLoginId": true},
|
map[string]any{"key": "emp_no", "isLoginId": true},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}, nil).Once()
|
}, nil).Once()
|
||||||
|
|
||||||
traits := map[string]interface{}{
|
traits := map[string]any{
|
||||||
"tenant_id": tenantID,
|
"tenant_id": tenantID,
|
||||||
}
|
}
|
||||||
metadata := map[string]any{
|
metadata := map[string]any{
|
||||||
tenantID: map[string]interface{}{
|
tenantID: map[string]any{
|
||||||
"emp_no": "E1001",
|
"emp_no": "E1001",
|
||||||
},
|
},
|
||||||
"worksmobileAliasEmails": map[string]interface{}{
|
"worksmobileAliasEmails": map[string]any{
|
||||||
"0": "alias@hanmaceng.co.kr",
|
"0": "alias@hanmaceng.co.kr",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -1482,7 +1498,7 @@ func TestUserHandler_UpdateUser_LoginIDSync(t *testing.T) {
|
|||||||
userID := "u-1"
|
userID := "u-1"
|
||||||
mockKratos.On("GetIdentity", mock.Anything, userID).Return(&service.KratosIdentity{
|
mockKratos.On("GetIdentity", mock.Anything, userID).Return(&service.KratosIdentity{
|
||||||
ID: userID,
|
ID: userID,
|
||||||
Traits: map[string]interface{}{
|
Traits: map[string]any{
|
||||||
"email": "user@test.com",
|
"email": "user@test.com",
|
||||||
"companyCode": "test-tenant",
|
"companyCode": "test-tenant",
|
||||||
"tenant_id": tenantID,
|
"tenant_id": tenantID,
|
||||||
@@ -1493,8 +1509,8 @@ func TestUserHandler_UpdateUser_LoginIDSync(t *testing.T) {
|
|||||||
ID: tenantID,
|
ID: tenantID,
|
||||||
Slug: "test-tenant",
|
Slug: "test-tenant",
|
||||||
Config: domain.JSONMap{
|
Config: domain.JSONMap{
|
||||||
"userSchema": []interface{}{
|
"userSchema": []any{
|
||||||
map[string]interface{}{"key": "emp_no", "label": "Employee No", "isLoginId": true},
|
map[string]any{"key": "emp_no", "label": "Employee No", "isLoginId": true},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}, nil)
|
}, nil)
|
||||||
@@ -1502,8 +1518,8 @@ func TestUserHandler_UpdateUser_LoginIDSync(t *testing.T) {
|
|||||||
ID: tenantID,
|
ID: tenantID,
|
||||||
Slug: "test-tenant",
|
Slug: "test-tenant",
|
||||||
Config: domain.JSONMap{
|
Config: domain.JSONMap{
|
||||||
"userSchema": []interface{}{
|
"userSchema": []any{
|
||||||
map[string]interface{}{"key": "emp_no", "label": "Employee No", "isLoginId": true},
|
map[string]any{"key": "emp_no", "label": "Employee No", "isLoginId": true},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}, nil)
|
}, nil)
|
||||||
@@ -1511,20 +1527,20 @@ func TestUserHandler_UpdateUser_LoginIDSync(t *testing.T) {
|
|||||||
mockTenant.On("ListManageableTenants", mock.Anything, userID).Return([]domain.Tenant{}, nil).Once()
|
mockTenant.On("ListManageableTenants", mock.Anything, userID).Return([]domain.Tenant{}, nil).Once()
|
||||||
|
|
||||||
// Expect traits to include 'custom_login_ids' synced from 'emp_no'
|
// Expect traits to include 'custom_login_ids' synced from 'emp_no'
|
||||||
mockKratos.On("UpdateIdentity", mock.Anything, userID, mock.MatchedBy(func(traits map[string]interface{}) bool {
|
mockKratos.On("UpdateIdentity", mock.Anything, userID, mock.MatchedBy(func(traits map[string]any) bool {
|
||||||
ids, ok := traits["custom_login_ids"].([]string)
|
ids, ok := traits["custom_login_ids"].([]string)
|
||||||
return ok && len(ids) > 0 && ids[0] == "E1001"
|
return ok && len(ids) > 0 && ids[0] == "E1001"
|
||||||
}), mock.Anything).Return(&service.KratosIdentity{
|
}), mock.Anything).Return(&service.KratosIdentity{
|
||||||
ID: userID,
|
ID: userID,
|
||||||
Traits: map[string]interface{}{
|
Traits: map[string]any{
|
||||||
"custom_login_ids": []interface{}{"E1001"},
|
"custom_login_ids": []any{"E1001"},
|
||||||
"email": "user@test.com",
|
"email": "user@test.com",
|
||||||
},
|
},
|
||||||
}, nil).Once()
|
}, nil).Once()
|
||||||
|
|
||||||
payload := map[string]interface{}{
|
payload := map[string]any{
|
||||||
"metadata": map[string]interface{}{
|
"metadata": map[string]any{
|
||||||
tenantID: map[string]interface{}{
|
tenantID: map[string]any{
|
||||||
"emp_no": "E1001",
|
"emp_no": "E1001",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -1555,12 +1571,12 @@ func TestUserHandler_UpdateUser_LoginIDSync(t *testing.T) {
|
|||||||
userID := "u-2"
|
userID := "u-2"
|
||||||
mockKratos.On("GetIdentity", mock.Anything, userID).Return(&service.KratosIdentity{
|
mockKratos.On("GetIdentity", mock.Anything, userID).Return(&service.KratosIdentity{
|
||||||
ID: userID,
|
ID: userID,
|
||||||
Traits: map[string]interface{}{
|
Traits: map[string]any{
|
||||||
"email": "user2@test.com",
|
"email": "user2@test.com",
|
||||||
"companyCode": "test-tenant",
|
"companyCode": "test-tenant",
|
||||||
"tenant_id": tenantID,
|
"tenant_id": tenantID,
|
||||||
"id": "old-id",
|
"id": "old-id",
|
||||||
tenantID: map[string]interface{}{
|
tenantID: map[string]any{
|
||||||
"emp_no": "E2002",
|
"emp_no": "E2002",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -1570,8 +1586,8 @@ func TestUserHandler_UpdateUser_LoginIDSync(t *testing.T) {
|
|||||||
ID: tenantID,
|
ID: tenantID,
|
||||||
Slug: "test-tenant",
|
Slug: "test-tenant",
|
||||||
Config: domain.JSONMap{
|
Config: domain.JSONMap{
|
||||||
"userSchema": []interface{}{
|
"userSchema": []any{
|
||||||
map[string]interface{}{"key": "emp_no", "isLoginId": true},
|
map[string]any{"key": "emp_no", "isLoginId": true},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}, nil)
|
}, nil)
|
||||||
@@ -1579,8 +1595,8 @@ func TestUserHandler_UpdateUser_LoginIDSync(t *testing.T) {
|
|||||||
ID: tenantID,
|
ID: tenantID,
|
||||||
Slug: "test-tenant",
|
Slug: "test-tenant",
|
||||||
Config: domain.JSONMap{
|
Config: domain.JSONMap{
|
||||||
"userSchema": []interface{}{
|
"userSchema": []any{
|
||||||
map[string]interface{}{"key": "emp_no", "isLoginId": true},
|
map[string]any{"key": "emp_no", "isLoginId": true},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}, nil)
|
}, nil)
|
||||||
@@ -1588,17 +1604,17 @@ func TestUserHandler_UpdateUser_LoginIDSync(t *testing.T) {
|
|||||||
mockTenant.On("ListManageableTenants", mock.Anything, userID).Return([]domain.Tenant{}, nil).Once()
|
mockTenant.On("ListManageableTenants", mock.Anything, userID).Return([]domain.Tenant{}, nil).Once()
|
||||||
|
|
||||||
// Even if metadata is empty, it should sync from existing traits
|
// Even if metadata is empty, it should sync from existing traits
|
||||||
mockKratos.On("UpdateIdentity", mock.Anything, userID, mock.MatchedBy(func(traits map[string]interface{}) bool {
|
mockKratos.On("UpdateIdentity", mock.Anything, userID, mock.MatchedBy(func(traits map[string]any) bool {
|
||||||
ids, ok := traits["custom_login_ids"].([]string)
|
ids, ok := traits["custom_login_ids"].([]string)
|
||||||
return ok && len(ids) > 0 && ids[0] == "E2002"
|
return ok && len(ids) > 0 && ids[0] == "E2002"
|
||||||
}), mock.Anything).Return(&service.KratosIdentity{
|
}), mock.Anything).Return(&service.KratosIdentity{
|
||||||
ID: userID,
|
ID: userID,
|
||||||
Traits: map[string]interface{}{
|
Traits: map[string]any{
|
||||||
"custom_login_ids": []interface{}{"E2002"},
|
"custom_login_ids": []any{"E2002"},
|
||||||
},
|
},
|
||||||
}, nil).Once()
|
}, nil).Once()
|
||||||
|
|
||||||
payload := map[string]interface{}{
|
payload := map[string]any{
|
||||||
"name": "New Name",
|
"name": "New Name",
|
||||||
}
|
}
|
||||||
body, _ := json.Marshal(payload)
|
body, _ := json.Marshal(payload)
|
||||||
@@ -1630,8 +1646,8 @@ func TestUserHandler_UpdateUser_PasswordUsesProvider(t *testing.T) {
|
|||||||
userID := "u-1"
|
userID := "u-1"
|
||||||
mockKratos.On("GetIdentity", mock.Anything, userID).Return(&service.KratosIdentity{
|
mockKratos.On("GetIdentity", mock.Anything, userID).Return(&service.KratosIdentity{
|
||||||
ID: userID,
|
ID: userID,
|
||||||
Traits: map[string]interface{}{
|
Traits: map[string]any{
|
||||||
"custom_login_ids": []interface{}{"dyddus1210"},
|
"custom_login_ids": []any{"dyddus1210"},
|
||||||
"email": "dyddus1210@gmail.com",
|
"email": "dyddus1210@gmail.com",
|
||||||
"companyCode": "test-tenant",
|
"companyCode": "test-tenant",
|
||||||
"tenant_id": "t-1",
|
"tenant_id": "t-1",
|
||||||
@@ -1643,8 +1659,8 @@ func TestUserHandler_UpdateUser_PasswordUsesProvider(t *testing.T) {
|
|||||||
ID: "t-1",
|
ID: "t-1",
|
||||||
Slug: "test-tenant",
|
Slug: "test-tenant",
|
||||||
Config: domain.JSONMap{
|
Config: domain.JSONMap{
|
||||||
"userSchema": []interface{}{
|
"userSchema": []any{
|
||||||
map[string]interface{}{"key": "emp_id", "isLoginId": true},
|
map[string]any{"key": "emp_id", "isLoginId": true},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}, nil)
|
}, nil)
|
||||||
@@ -1652,27 +1668,27 @@ func TestUserHandler_UpdateUser_PasswordUsesProvider(t *testing.T) {
|
|||||||
ID: "t-1",
|
ID: "t-1",
|
||||||
Slug: "test-tenant",
|
Slug: "test-tenant",
|
||||||
Config: domain.JSONMap{
|
Config: domain.JSONMap{
|
||||||
"userSchema": []interface{}{
|
"userSchema": []any{
|
||||||
map[string]interface{}{"key": "emp_id", "isLoginId": true},
|
map[string]any{"key": "emp_id", "isLoginId": true},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}, nil)
|
}, nil)
|
||||||
mockTenant.On("ListManageableTenants", mock.Anything, userID).Return([]domain.Tenant{}, nil).Once()
|
mockTenant.On("ListManageableTenants", mock.Anything, userID).Return([]domain.Tenant{}, nil).Once()
|
||||||
|
|
||||||
mockKratos.On("UpdateIdentity", mock.Anything, userID, mock.MatchedBy(func(traits map[string]interface{}) bool {
|
mockKratos.On("UpdateIdentity", mock.Anything, userID, mock.MatchedBy(func(traits map[string]any) bool {
|
||||||
ids, ok := traits["custom_login_ids"].([]string)
|
ids, ok := traits["custom_login_ids"].([]string)
|
||||||
return ok && len(ids) > 0 && ids[0] == "dyddus1210"
|
return ok && len(ids) > 0 && ids[0] == "dyddus1210"
|
||||||
}), "").Return(&service.KratosIdentity{
|
}), "").Return(&service.KratosIdentity{
|
||||||
ID: userID,
|
ID: userID,
|
||||||
Traits: map[string]interface{}{
|
Traits: map[string]any{
|
||||||
"custom_login_ids": []interface{}{"dyddus1210"},
|
"custom_login_ids": []any{"dyddus1210"},
|
||||||
"email": "dyddus1210@gmail.com",
|
"email": "dyddus1210@gmail.com",
|
||||||
},
|
},
|
||||||
}, nil).Once()
|
}, nil).Once()
|
||||||
|
|
||||||
mockOry.On("UpdateUserPassword", "dyddus1210", "asdfzxcv1234!", (*http.Request)(nil)).Return(nil).Once()
|
mockOry.On("UpdateUserPassword", "dyddus1210", "asdfzxcv1234!", (*http.Request)(nil)).Return(nil).Once()
|
||||||
|
|
||||||
payload := map[string]interface{}{
|
payload := map[string]any{
|
||||||
"password": "asdfzxcv1234!",
|
"password": "asdfzxcv1234!",
|
||||||
}
|
}
|
||||||
body, _ := json.Marshal(payload)
|
body, _ := json.Marshal(payload)
|
||||||
@@ -1704,7 +1720,7 @@ func TestUserHandler_UpdateUser_PasswordFallsBackToEmail(t *testing.T) {
|
|||||||
userID := "u-2"
|
userID := "u-2"
|
||||||
mockKratos.On("GetIdentity", mock.Anything, userID).Return(&service.KratosIdentity{
|
mockKratos.On("GetIdentity", mock.Anything, userID).Return(&service.KratosIdentity{
|
||||||
ID: userID,
|
ID: userID,
|
||||||
Traits: map[string]interface{}{
|
Traits: map[string]any{
|
||||||
"email": "dyddus1210@gmail.com",
|
"email": "dyddus1210@gmail.com",
|
||||||
"companyCode": "test-tenant",
|
"companyCode": "test-tenant",
|
||||||
},
|
},
|
||||||
@@ -1716,18 +1732,18 @@ func TestUserHandler_UpdateUser_PasswordFallsBackToEmail(t *testing.T) {
|
|||||||
}, nil)
|
}, nil)
|
||||||
mockTenant.On("ListManageableTenants", mock.Anything, userID).Return([]domain.Tenant{}, nil).Once()
|
mockTenant.On("ListManageableTenants", mock.Anything, userID).Return([]domain.Tenant{}, nil).Once()
|
||||||
|
|
||||||
mockKratos.On("UpdateIdentity", mock.Anything, userID, mock.MatchedBy(func(traits map[string]interface{}) bool {
|
mockKratos.On("UpdateIdentity", mock.Anything, userID, mock.MatchedBy(func(traits map[string]any) bool {
|
||||||
return traits["email"] == "dyddus1210@gmail.com"
|
return traits["email"] == "dyddus1210@gmail.com"
|
||||||
}), "").Return(&service.KratosIdentity{
|
}), "").Return(&service.KratosIdentity{
|
||||||
ID: userID,
|
ID: userID,
|
||||||
Traits: map[string]interface{}{
|
Traits: map[string]any{
|
||||||
"email": "dyddus1210@gmail.com",
|
"email": "dyddus1210@gmail.com",
|
||||||
},
|
},
|
||||||
}, nil).Once()
|
}, nil).Once()
|
||||||
|
|
||||||
mockOry.On("UpdateUserPassword", "dyddus1210@gmail.com", "asdfzxcv1234!", (*http.Request)(nil)).Return(nil).Once()
|
mockOry.On("UpdateUserPassword", "dyddus1210@gmail.com", "asdfzxcv1234!", (*http.Request)(nil)).Return(nil).Once()
|
||||||
|
|
||||||
payload := map[string]interface{}{
|
payload := map[string]any{
|
||||||
"password": "asdfzxcv1234!",
|
"password": "asdfzxcv1234!",
|
||||||
}
|
}
|
||||||
body, _ := json.Marshal(payload)
|
body, _ := json.Marshal(payload)
|
||||||
@@ -1757,8 +1773,8 @@ func TestUserHandler_CreateUser_LoginIDSync(t *testing.T) {
|
|||||||
ID: tenantID,
|
ID: tenantID,
|
||||||
Slug: "test-tenant",
|
Slug: "test-tenant",
|
||||||
Config: domain.JSONMap{
|
Config: domain.JSONMap{
|
||||||
"userSchema": []interface{}{
|
"userSchema": []any{
|
||||||
map[string]interface{}{"key": "emp_no", "label": "Employee No", "isLoginId": true},
|
map[string]any{"key": "emp_no", "label": "Employee No", "isLoginId": true},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}, nil)
|
}, nil)
|
||||||
@@ -1766,8 +1782,8 @@ func TestUserHandler_CreateUser_LoginIDSync(t *testing.T) {
|
|||||||
ID: tenantID,
|
ID: tenantID,
|
||||||
Slug: "test-tenant",
|
Slug: "test-tenant",
|
||||||
Config: domain.JSONMap{
|
Config: domain.JSONMap{
|
||||||
"userSchema": []interface{}{
|
"userSchema": []any{
|
||||||
map[string]interface{}{"key": "emp_no", "label": "Employee No", "isLoginId": true},
|
map[string]any{"key": "emp_no", "label": "Employee No", "isLoginId": true},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}, nil)
|
}, nil)
|
||||||
@@ -1783,8 +1799,8 @@ func TestUserHandler_CreateUser_LoginIDSync(t *testing.T) {
|
|||||||
// Mock GetIdentity after creation
|
// Mock GetIdentity after creation
|
||||||
mockKratos.On("GetIdentity", mock.Anything, "u-1").Return(&service.KratosIdentity{
|
mockKratos.On("GetIdentity", mock.Anything, "u-1").Return(&service.KratosIdentity{
|
||||||
ID: "u-1",
|
ID: "u-1",
|
||||||
Traits: map[string]interface{}{
|
Traits: map[string]any{
|
||||||
"custom_login_ids": []interface{}{"E1001"},
|
"custom_login_ids": []any{"E1001"},
|
||||||
"email": "new@test.com",
|
"email": "new@test.com",
|
||||||
"companyCode": "test-tenant",
|
"companyCode": "test-tenant",
|
||||||
},
|
},
|
||||||
@@ -1793,12 +1809,12 @@ func TestUserHandler_CreateUser_LoginIDSync(t *testing.T) {
|
|||||||
// Mock ListManageableTenants for mapIdentitySummary
|
// Mock ListManageableTenants for mapIdentitySummary
|
||||||
mockTenant.On("ListManageableTenants", mock.Anything, "u-1").Return([]domain.Tenant{}, nil).Once()
|
mockTenant.On("ListManageableTenants", mock.Anything, "u-1").Return([]domain.Tenant{}, nil).Once()
|
||||||
|
|
||||||
payload := map[string]interface{}{
|
payload := map[string]any{
|
||||||
"email": "new@test.com",
|
"email": "new@test.com",
|
||||||
"name": "New User",
|
"name": "New User",
|
||||||
"tenantSlug": "test-tenant",
|
"tenantSlug": "test-tenant",
|
||||||
"metadata": map[string]interface{}{
|
"metadata": map[string]any{
|
||||||
tenantID: map[string]interface{}{
|
tenantID: map[string]any{
|
||||||
"emp_no": "E1001",
|
"emp_no": "E1001",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -1849,25 +1865,25 @@ func TestUserHandler_CreateUser_UsesAdditionalAppointmentAsPrimaryTenant(t *test
|
|||||||
}), mock.Anything).Return("u-appointment", nil).Once()
|
}), mock.Anything).Return("u-appointment", nil).Once()
|
||||||
mockKratos.On("GetIdentity", mock.Anything, "u-appointment").Return(&service.KratosIdentity{
|
mockKratos.On("GetIdentity", mock.Anything, "u-appointment").Return(&service.KratosIdentity{
|
||||||
ID: "u-appointment",
|
ID: "u-appointment",
|
||||||
Traits: map[string]interface{}{
|
Traits: map[string]any{
|
||||||
"email": "new@samaneng.com",
|
"email": "new@samaneng.com",
|
||||||
"name": "Appointment User",
|
"name": "Appointment User",
|
||||||
"companyCode": "saman",
|
"companyCode": "saman",
|
||||||
"tenant_id": tenantID,
|
"tenant_id": tenantID,
|
||||||
"additionalAppointments": []interface{}{
|
"additionalAppointments": []any{
|
||||||
map[string]interface{}{"tenantId": tenantID, "tenantSlug": "saman"},
|
map[string]any{"tenantId": tenantID, "tenantSlug": "saman"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
State: "active",
|
State: "active",
|
||||||
}, nil).Once()
|
}, nil).Once()
|
||||||
|
|
||||||
payload := map[string]interface{}{
|
payload := map[string]any{
|
||||||
"email": "new@samaneng.com",
|
"email": "new@samaneng.com",
|
||||||
"name": "Appointment User",
|
"name": "Appointment User",
|
||||||
"additionalAppointments": []map[string]interface{}{
|
"additionalAppointments": []map[string]any{
|
||||||
{"tenantId": tenantID, "tenantSlug": "saman", "tenantName": "삼안"},
|
{"tenantId": tenantID, "tenantSlug": "saman", "tenantName": "삼안"},
|
||||||
},
|
},
|
||||||
"metadata": map[string]interface{}{
|
"metadata": map[string]any{
|
||||||
"userType": "hanmac",
|
"userType": "hanmac",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -1939,7 +1955,7 @@ func TestUserHandler_CreateUser_AutoCreatesPersonalTenantWhenAssignmentMissing(t
|
|||||||
}), mock.Anything).Return("u-personal", nil).Once()
|
}), mock.Anything).Return("u-personal", nil).Once()
|
||||||
mockKratos.On("GetIdentity", mock.Anything, "u-personal").Return(&service.KratosIdentity{
|
mockKratos.On("GetIdentity", mock.Anything, "u-personal").Return(&service.KratosIdentity{
|
||||||
ID: "u-personal",
|
ID: "u-personal",
|
||||||
Traits: map[string]interface{}{
|
Traits: map[string]any{
|
||||||
"email": "personal-user@example.com",
|
"email": "personal-user@example.com",
|
||||||
"name": "Personal User",
|
"name": "Personal User",
|
||||||
"companyCode": "personal-01970f0d96667548963d2890351f03dd",
|
"companyCode": "personal-01970f0d96667548963d2890351f03dd",
|
||||||
@@ -1947,7 +1963,7 @@ func TestUserHandler_CreateUser_AutoCreatesPersonalTenantWhenAssignmentMissing(t
|
|||||||
},
|
},
|
||||||
State: "active",
|
State: "active",
|
||||||
}, nil).Once()
|
}, nil).Once()
|
||||||
payload := map[string]interface{}{
|
payload := map[string]any{
|
||||||
"email": "personal-user@example.com",
|
"email": "personal-user@example.com",
|
||||||
"name": "Personal User",
|
"name": "Personal User",
|
||||||
}
|
}
|
||||||
@@ -1992,7 +2008,7 @@ func TestUserHandler_CreateUserAcceptsTenantSlugAndRejectsCompanyCode(t *testing
|
|||||||
mockKratos.On("GetIdentity", mock.Anything, "user-id").Return(&service.KratosIdentity{
|
mockKratos.On("GetIdentity", mock.Anything, "user-id").Return(&service.KratosIdentity{
|
||||||
ID: "user-id",
|
ID: "user-id",
|
||||||
State: "active",
|
State: "active",
|
||||||
Traits: map[string]interface{}{
|
Traits: map[string]any{
|
||||||
"email": "user@test.com",
|
"email": "user@test.com",
|
||||||
"name": "Test User",
|
"name": "Test User",
|
||||||
"tenant_id": "tenant-id",
|
"tenant_id": "tenant-id",
|
||||||
@@ -2029,7 +2045,7 @@ func TestUserHandler_UpdateUserAcceptsTenantSlugAndRejectsCompanyCode(t *testing
|
|||||||
identity := &service.KratosIdentity{
|
identity := &service.KratosIdentity{
|
||||||
ID: "user-id",
|
ID: "user-id",
|
||||||
State: "active",
|
State: "active",
|
||||||
Traits: map[string]interface{}{
|
Traits: map[string]any{
|
||||||
"email": "user@test.com",
|
"email": "user@test.com",
|
||||||
"name": "Test User",
|
"name": "Test User",
|
||||||
"tenant_id": "old-tenant-id",
|
"tenant_id": "old-tenant-id",
|
||||||
@@ -2046,13 +2062,13 @@ func TestUserHandler_UpdateUserAcceptsTenantSlugAndRejectsCompanyCode(t *testing
|
|||||||
Slug: "new-tenant",
|
Slug: "new-tenant",
|
||||||
Config: domain.JSONMap{},
|
Config: domain.JSONMap{},
|
||||||
}, nil).Once()
|
}, nil).Once()
|
||||||
mockKratos.On("UpdateIdentity", mock.Anything, "user-id", mock.MatchedBy(func(traits map[string]interface{}) bool {
|
mockKratos.On("UpdateIdentity", mock.Anything, "user-id", mock.MatchedBy(func(traits map[string]any) bool {
|
||||||
_, hasCompanyCode := traits["companyCode"]
|
_, hasCompanyCode := traits["companyCode"]
|
||||||
return !hasCompanyCode && traits["tenant_id"] == "new-tenant-id"
|
return !hasCompanyCode && traits["tenant_id"] == "new-tenant-id"
|
||||||
}), "").Return(&service.KratosIdentity{
|
}), "").Return(&service.KratosIdentity{
|
||||||
ID: "user-id",
|
ID: "user-id",
|
||||||
State: "active",
|
State: "active",
|
||||||
Traits: map[string]interface{}{
|
Traits: map[string]any{
|
||||||
"email": "user@test.com",
|
"email": "user@test.com",
|
||||||
"name": "Test User",
|
"name": "Test User",
|
||||||
"tenant_id": "new-tenant-id",
|
"tenant_id": "new-tenant-id",
|
||||||
@@ -2091,7 +2107,7 @@ func TestUserHandler_BulkUpdateUsersAcceptsTenantSlugAndRejectsCompanyCode(t *te
|
|||||||
mockKratos.On("GetIdentity", mock.Anything, "user-id").Return(&service.KratosIdentity{
|
mockKratos.On("GetIdentity", mock.Anything, "user-id").Return(&service.KratosIdentity{
|
||||||
ID: "user-id",
|
ID: "user-id",
|
||||||
State: "active",
|
State: "active",
|
||||||
Traits: map[string]interface{}{
|
Traits: map[string]any{
|
||||||
"email": "user@test.com",
|
"email": "user@test.com",
|
||||||
"name": "Test User",
|
"name": "Test User",
|
||||||
"tenant_id": "old-tenant-id",
|
"tenant_id": "old-tenant-id",
|
||||||
@@ -2102,13 +2118,13 @@ func TestUserHandler_BulkUpdateUsersAcceptsTenantSlugAndRejectsCompanyCode(t *te
|
|||||||
ID: "new-tenant-id",
|
ID: "new-tenant-id",
|
||||||
Slug: "new-tenant",
|
Slug: "new-tenant",
|
||||||
}, nil).Once()
|
}, nil).Once()
|
||||||
mockKratos.On("UpdateIdentity", mock.Anything, "user-id", mock.MatchedBy(func(traits map[string]interface{}) bool {
|
mockKratos.On("UpdateIdentity", mock.Anything, "user-id", mock.MatchedBy(func(traits map[string]any) bool {
|
||||||
_, hasCompanyCode := traits["companyCode"]
|
_, hasCompanyCode := traits["companyCode"]
|
||||||
return !hasCompanyCode && traits["tenant_id"] == "new-tenant-id"
|
return !hasCompanyCode && traits["tenant_id"] == "new-tenant-id"
|
||||||
}), "active").Return(&service.KratosIdentity{
|
}), "active").Return(&service.KratosIdentity{
|
||||||
ID: "user-id",
|
ID: "user-id",
|
||||||
State: "active",
|
State: "active",
|
||||||
Traits: map[string]interface{}{
|
Traits: map[string]any{
|
||||||
"email": "user@test.com",
|
"email": "user@test.com",
|
||||||
"name": "Test User",
|
"name": "Test User",
|
||||||
"tenant_id": "new-tenant-id",
|
"tenant_id": "new-tenant-id",
|
||||||
@@ -2137,7 +2153,7 @@ func TestUserHandler_MapToLocalUserKeepsRoleAndGradeSeparate(t *testing.T) {
|
|||||||
identity := service.KratosIdentity{
|
identity := service.KratosIdentity{
|
||||||
ID: "user-grade-id",
|
ID: "user-grade-id",
|
||||||
State: "active",
|
State: "active",
|
||||||
Traits: map[string]interface{}{
|
Traits: map[string]any{
|
||||||
"email": "grade@example.com",
|
"email": "grade@example.com",
|
||||||
"name": "Grade User",
|
"name": "Grade User",
|
||||||
"role": domain.RoleUser,
|
"role": domain.RoleUser,
|
||||||
|
|||||||
257
backend/internal/handler/user_handler_uuid_test.go
Normal file
257
backend/internal/handler/user_handler_uuid_test.go
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"baron-sso-backend/internal/domain"
|
||||||
|
"baron-sso-backend/internal/service"
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/mock"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestUserHandler_BulkCreateUsers_UUIDSupport(t *testing.T) {
|
||||||
|
app := fiber.New()
|
||||||
|
mockKratos := new(MockKratosAdmin)
|
||||||
|
mockOry := new(MockOryProvider)
|
||||||
|
mockTenant := new(MockTenantServiceForUser)
|
||||||
|
|
||||||
|
h := &UserHandler{
|
||||||
|
KratosAdmin: mockKratos,
|
||||||
|
OryProvider: mockOry,
|
||||||
|
TenantService: mockTenant,
|
||||||
|
}
|
||||||
|
|
||||||
|
app.Post("/users/bulk", h.BulkCreateUsers)
|
||||||
|
|
||||||
|
t.Run("Success - Provided UUID", func(t *testing.T) {
|
||||||
|
testUuid := "550e8400-e29b-41d4-a716-446655440000"
|
||||||
|
mockTenant.On("GetTenantBySlug", mock.Anything, "test-tenant").Return(&domain.Tenant{
|
||||||
|
ID: "t-1",
|
||||||
|
Slug: "test-tenant",
|
||||||
|
}, nil).Once()
|
||||||
|
|
||||||
|
mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil)
|
||||||
|
|
||||||
|
// 1. Search-first check: Simulate user NOT found by this UUID
|
||||||
|
mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, testUuid).Return("", nil).Once()
|
||||||
|
|
||||||
|
// 2. Create identity
|
||||||
|
mockOry.On("CreateUser", mock.MatchedBy(func(user *domain.BrokerUser) bool {
|
||||||
|
return user.ID == testUuid && user.Email == "uuid@test.com"
|
||||||
|
}), mock.Anything).Return(testUuid, nil).Once()
|
||||||
|
|
||||||
|
payload := map[string]any{
|
||||||
|
"users": []map[string]any{
|
||||||
|
{
|
||||||
|
"email": "uuid@test.com",
|
||||||
|
"name": "UUID User",
|
||||||
|
"tenantSlug": "test-tenant",
|
||||||
|
"id": testUuid,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(payload)
|
||||||
|
req := httptest.NewRequest("POST", "/users/bulk", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, _ := app.Test(req)
|
||||||
|
assert.Equal(t, 200, resp.StatusCode)
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
Results []bulkUserResult `json:"results"`
|
||||||
|
}
|
||||||
|
json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
assert.Len(t, result.Results, 1)
|
||||||
|
assert.True(t, result.Results[0].Success)
|
||||||
|
assert.Equal(t, testUuid, result.Results[0].UserID)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Success - Provided UUID Already Exists (Idempotency)", func(t *testing.T) {
|
||||||
|
testUuid := "550e8400-e29b-41d4-a716-446655440005"
|
||||||
|
mockTenant.On("GetTenantBySlug", mock.Anything, "test-tenant").Return(&domain.Tenant{
|
||||||
|
ID: "t-1",
|
||||||
|
Slug: "test-tenant",
|
||||||
|
}, nil).Once()
|
||||||
|
|
||||||
|
mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil)
|
||||||
|
|
||||||
|
// 1. Search-first check: Simulate user FOUND by this UUID
|
||||||
|
mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, testUuid).Return(testUuid, nil).Once()
|
||||||
|
|
||||||
|
// 2. Fetch existing identity for field comparison
|
||||||
|
mockKratos.On("GetIdentity", mock.Anything, testUuid).Return(&service.KratosIdentity{
|
||||||
|
ID: testUuid,
|
||||||
|
Traits: map[string]any{
|
||||||
|
"email": "existing@test.com",
|
||||||
|
"name": "Old Name",
|
||||||
|
},
|
||||||
|
}, nil).Once()
|
||||||
|
|
||||||
|
payload := map[string]any{
|
||||||
|
"users": []map[string]any{
|
||||||
|
{
|
||||||
|
"email": "existing@test.com",
|
||||||
|
"name": "Existing User",
|
||||||
|
"tenantSlug": "test-tenant",
|
||||||
|
"id": testUuid,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(payload)
|
||||||
|
req := httptest.NewRequest("POST", "/users/bulk", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, _ := app.Test(req)
|
||||||
|
assert.Equal(t, 200, resp.StatusCode)
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
Results []bulkUserResult `json:"results"`
|
||||||
|
}
|
||||||
|
json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
assert.Len(t, result.Results, 1)
|
||||||
|
assert.True(t, result.Results[0].Success)
|
||||||
|
assert.Equal(t, testUuid, result.Results[0].UserID)
|
||||||
|
assert.Equal(t, "updated", result.Results[0].Status)
|
||||||
|
// Should have "Name" in modified fields since we changed from "Old Name" to "Existing User"
|
||||||
|
assert.Contains(t, result.Results[0].ModifiedFields, "Name")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Fail - Duplicate UUID Conflict", func(t *testing.T) {
|
||||||
|
testUuid := "550e8400-e29b-41d4-a716-446655440001"
|
||||||
|
mockTenant.On("GetTenantBySlug", mock.Anything, "test-tenant").Return(&domain.Tenant{
|
||||||
|
ID: "t-1",
|
||||||
|
Slug: "test-tenant",
|
||||||
|
}, nil).Once()
|
||||||
|
|
||||||
|
mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil)
|
||||||
|
|
||||||
|
// 1. Search-first check: Simulate NOT found by this UUID (initial check)
|
||||||
|
mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, testUuid).Return("", nil).Once()
|
||||||
|
|
||||||
|
// 2. Create identity: Simulate Ory returning a 409 conflict for the UUID
|
||||||
|
conflictErr := fmt.Errorf("ory provider: identity already exists for uuid=%s", testUuid)
|
||||||
|
mockOry.On("CreateUser", mock.MatchedBy(func(user *domain.BrokerUser) bool {
|
||||||
|
return user.ID == testUuid
|
||||||
|
}), mock.Anything).Return("", conflictErr).Once()
|
||||||
|
|
||||||
|
// 3. Conflict double-check: Search by EMAIL to see if it's the same person
|
||||||
|
// If NOT found by email, it means it's a different person using this UUID
|
||||||
|
mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, "conflict@test.com").Return("", nil).Once()
|
||||||
|
|
||||||
|
payload := map[string]any{
|
||||||
|
"users": []map[string]any{
|
||||||
|
{
|
||||||
|
"email": "conflict@test.com",
|
||||||
|
"name": "Conflict User",
|
||||||
|
"tenantSlug": "test-tenant",
|
||||||
|
"uuid": testUuid,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(payload)
|
||||||
|
req := httptest.NewRequest("POST", "/users/bulk", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, _ := app.Test(req)
|
||||||
|
assert.Equal(t, 200, resp.StatusCode)
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
Results []bulkUserResult `json:"results"`
|
||||||
|
}
|
||||||
|
json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
assert.Len(t, result.Results, 1)
|
||||||
|
assert.False(t, result.Results[0].Success)
|
||||||
|
assert.Contains(t, result.Results[0].Message, "Conflict: UUID already exists")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Fail - Invalid UUID Format", func(t *testing.T) {
|
||||||
|
invalidUuid := "not-a-uuid"
|
||||||
|
mockTenant.On("GetTenantBySlug", mock.Anything, "test-tenant").Return(&domain.Tenant{
|
||||||
|
ID: "t-1",
|
||||||
|
Slug: "test-tenant",
|
||||||
|
}, nil).Once()
|
||||||
|
|
||||||
|
mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil)
|
||||||
|
|
||||||
|
// 1. Search-first check (even for invalid format, it currently tries to search)
|
||||||
|
mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, invalidUuid).Return("", nil).Once()
|
||||||
|
|
||||||
|
payload := map[string]any{
|
||||||
|
"users": []map[string]any{
|
||||||
|
{
|
||||||
|
"email": "invalid@test.com",
|
||||||
|
"name": "Invalid User",
|
||||||
|
"tenantSlug": "test-tenant",
|
||||||
|
"id": invalidUuid,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(payload)
|
||||||
|
req := httptest.NewRequest("POST", "/users/bulk", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, _ := app.Test(req)
|
||||||
|
assert.Equal(t, 200, resp.StatusCode)
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
Results []bulkUserResult `json:"results"`
|
||||||
|
}
|
||||||
|
json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
assert.Len(t, result.Results, 1)
|
||||||
|
assert.False(t, result.Results[0].Success)
|
||||||
|
assert.Contains(t, result.Results[0].Message, "invalid UUID format")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Success - Import after Delete (Visibility Check)", func(t *testing.T) {
|
||||||
|
// This test is to ensure that if a user is deleted and then re-imported with the same UUID,
|
||||||
|
// the process succeeds and ideally would result in a visible user.
|
||||||
|
// NOTE: In this unit test we mock the Repository, so we can't fully test DB behavior,
|
||||||
|
// but we can verify the Handler logic flow.
|
||||||
|
|
||||||
|
testUuid := "550e8400-e29b-41d4-a716-446655440007"
|
||||||
|
mockTenant.On("GetTenantBySlug", mock.Anything, "test-tenant").Return(&domain.Tenant{
|
||||||
|
ID: "t-1",
|
||||||
|
Slug: "test-tenant",
|
||||||
|
}, nil).Once()
|
||||||
|
|
||||||
|
mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil)
|
||||||
|
|
||||||
|
// 1. Search-first: Simulate NOT found (it was deleted from Kratos)
|
||||||
|
mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, testUuid).Return("", nil).Once()
|
||||||
|
|
||||||
|
// 2. Create identity: Succeeds and returns a NEW Kratos ID
|
||||||
|
newKratosId := "new-kratos-id-after-delete"
|
||||||
|
mockOry.On("CreateUser", mock.MatchedBy(func(user *domain.BrokerUser) bool {
|
||||||
|
return user.ID == testUuid
|
||||||
|
}), mock.Anything).Return(newKratosId, nil).Once()
|
||||||
|
|
||||||
|
payload := map[string]any{
|
||||||
|
"users": []map[string]any{
|
||||||
|
{
|
||||||
|
"email": "reimport@test.com",
|
||||||
|
"name": "Re-import User",
|
||||||
|
"tenantSlug": "test-tenant",
|
||||||
|
"id": testUuid,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(payload)
|
||||||
|
req := httptest.NewRequest("POST", "/users/bulk", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, _ := app.Test(req)
|
||||||
|
assert.Equal(t, 200, resp.StatusCode)
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
Results []bulkUserResult `json:"results"`
|
||||||
|
}
|
||||||
|
json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
assert.True(t, result.Results[0].Success)
|
||||||
|
assert.Equal(t, testUuid, result.Results[0].UserID)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"maps"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -196,14 +197,10 @@ func TestGetClient_ClientSecretFallbackPaths(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
secretRepo := &mockSecretRepo{secrets: map[string]string{}}
|
secretRepo := &mockSecretRepo{secrets: map[string]string{}}
|
||||||
for k, v := range tt.initialSecrets {
|
maps.Copy(secretRepo.secrets, tt.initialSecrets)
|
||||||
secretRepo.secrets[k] = v
|
|
||||||
}
|
|
||||||
|
|
||||||
redisRepo := &mockRedisRepo{data: map[string]string{}}
|
redisRepo := &mockRedisRepo{data: map[string]string{}}
|
||||||
for k, v := range tt.initialRedis {
|
maps.Copy(redisRepo.data, tt.initialRedis)
|
||||||
redisRepo.data[k] = v
|
|
||||||
}
|
|
||||||
|
|
||||||
h := &handler.DevHandler{
|
h := &handler.DevHandler{
|
||||||
Hydra: &service.HydraAdminService{
|
Hydra: &service.HydraAdminService{
|
||||||
|
|||||||
@@ -104,11 +104,11 @@ func SanitizeClientLogMessage(message string) string {
|
|||||||
return sanitized
|
return sanitized
|
||||||
}
|
}
|
||||||
|
|
||||||
func SanitizeClientLogData(data map[string]interface{}) map[string]interface{} {
|
func SanitizeClientLogData(data map[string]any) map[string]any {
|
||||||
if len(data) == 0 {
|
if len(data) == 0 {
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
out := make(map[string]interface{}, len(data))
|
out := make(map[string]any, len(data))
|
||||||
for k, v := range data {
|
for k, v := range data {
|
||||||
if isSensitiveClientLogKey(k) {
|
if isSensitiveClientLogKey(k) {
|
||||||
out[k] = "*****"
|
out[k] = "*****"
|
||||||
@@ -119,12 +119,12 @@ func SanitizeClientLogData(data map[string]interface{}) map[string]interface{} {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func sanitizeClientLogValue(v interface{}) interface{} {
|
func sanitizeClientLogValue(v any) any {
|
||||||
switch val := v.(type) {
|
switch val := v.(type) {
|
||||||
case map[string]interface{}:
|
case map[string]any:
|
||||||
return SanitizeClientLogData(val)
|
return SanitizeClientLogData(val)
|
||||||
case []interface{}:
|
case []any:
|
||||||
next := make([]interface{}, len(val))
|
next := make([]any, len(val))
|
||||||
for i := range val {
|
for i := range val {
|
||||||
next[i] = sanitizeClientLogValue(val[i])
|
next[i] = sanitizeClientLogValue(val[i])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,15 +57,15 @@ func TestShouldFilterNoisyClientInfo(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSanitizeClientLogData(t *testing.T) {
|
func TestSanitizeClientLogData(t *testing.T) {
|
||||||
input := map[string]interface{}{
|
input := map[string]any{
|
||||||
"token": "raw-token",
|
"token": "raw-token",
|
||||||
"safe": "ok",
|
"safe": "ok",
|
||||||
"nested": map[string]interface{}{
|
"nested": map[string]any{
|
||||||
"new_password": "secret-1",
|
"new_password": "secret-1",
|
||||||
"path": "/ko/profile",
|
"path": "/ko/profile",
|
||||||
},
|
},
|
||||||
"arr": []interface{}{
|
"arr": []any{
|
||||||
map[string]interface{}{"authorization": "Bearer abc"},
|
map[string]any{"authorization": "Bearer abc"},
|
||||||
"token=abc123",
|
"token=abc123",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -75,12 +75,12 @@ func TestSanitizeClientLogData(t *testing.T) {
|
|||||||
assert.Equal(t, "*****", result["token"])
|
assert.Equal(t, "*****", result["token"])
|
||||||
assert.Equal(t, "ok", result["safe"])
|
assert.Equal(t, "ok", result["safe"])
|
||||||
|
|
||||||
nested := result["nested"].(map[string]interface{})
|
nested := result["nested"].(map[string]any)
|
||||||
assert.Equal(t, "*****", nested["new_password"])
|
assert.Equal(t, "*****", nested["new_password"])
|
||||||
assert.Equal(t, "/ko/profile", nested["path"])
|
assert.Equal(t, "/ko/profile", nested["path"])
|
||||||
|
|
||||||
arr := result["arr"].([]interface{})
|
arr := result["arr"].([]any)
|
||||||
first := arr[0].(map[string]interface{})
|
first := arr[0].(map[string]any)
|
||||||
assert.Equal(t, "*****", first["authorization"])
|
assert.Equal(t, "*****", first["authorization"])
|
||||||
assert.Equal(t, "token=*****", arr[1])
|
assert.Equal(t, "token=*****", arr[1])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ func TestResolveBackendLogLevel_DefaultsByAppEnv(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, tc := range testCases {
|
for _, tc := range testCases {
|
||||||
tc := tc
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"maps"
|
||||||
"reflect"
|
"reflect"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -28,7 +29,7 @@ func isNil(i any) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
v := reflect.ValueOf(i)
|
v := reflect.ValueOf(i)
|
||||||
return v.Kind() == reflect.Ptr && v.IsNil()
|
return v.Kind() == reflect.Pointer && v.IsNil()
|
||||||
}
|
}
|
||||||
|
|
||||||
// AuditMiddleware provides comprehensive audit logging for write requests by default.
|
// AuditMiddleware provides comprehensive audit logging for write requests by default.
|
||||||
@@ -153,10 +154,8 @@ func AuditMiddleware(config AuditConfig) fiber.Handler {
|
|||||||
for key, value := range v {
|
for key, value := range v {
|
||||||
details[key] = value
|
details[key] = value
|
||||||
}
|
}
|
||||||
case map[string]interface{}:
|
case map[string]any:
|
||||||
for key, value := range v {
|
maps.Copy(details, v)
|
||||||
details[key] = value
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if skipTimeline, ok := c.Locals("auth_timeline_skip").(bool); ok && skipTimeline {
|
if skipTimeline, ok := c.Locals("auth_timeline_skip").(bool); ok && skipTimeline {
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ func (r *clientConsentRepo) Find(ctx context.Context, clientID, subject string)
|
|||||||
func (r *clientConsentRepo) Upsert(ctx context.Context, consent *domain.ClientConsent) error {
|
func (r *clientConsentRepo) Upsert(ctx context.Context, consent *domain.ClientConsent) error {
|
||||||
return r.db.WithContext(ctx).Unscoped().
|
return r.db.WithContext(ctx).Unscoped().
|
||||||
Where("client_id = ? AND subject = ?", consent.ClientID, consent.Subject).
|
Where("client_id = ? AND subject = ?", consent.ClientID, consent.Subject).
|
||||||
Assign(map[string]interface{}{
|
Assign(map[string]any{
|
||||||
"granted_scopes": consent.GrantedScopes,
|
"granted_scopes": consent.GrantedScopes,
|
||||||
"updated_at": gorm.Expr("NOW()"),
|
"updated_at": gorm.Expr("NOW()"),
|
||||||
"deleted_at": nil,
|
"deleted_at": nil,
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ func (r *ketoOutboxRepository) ListCurrentBySubject(ctx context.Context, namespa
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *ketoOutboxRepository) UpdateStatus(ctx context.Context, id string, status string, retryCount int, lastError string) error {
|
func (r *ketoOutboxRepository) UpdateStatus(ctx context.Context, id string, status string, retryCount int, lastError string) error {
|
||||||
return r.db.WithContext(ctx).Model(&domain.KetoOutbox{}).Where("id = ?", id).Updates(map[string]interface{}{
|
return r.db.WithContext(ctx).Model(&domain.KetoOutbox{}).Where("id = ?", id).Updates(map[string]any{
|
||||||
"status": status,
|
"status": status,
|
||||||
"retry_count": retryCount,
|
"retry_count": retryCount,
|
||||||
"last_error": lastError,
|
"last_error": lastError,
|
||||||
@@ -80,7 +80,7 @@ func (r *ketoOutboxRepository) UpdateStatus(ctx context.Context, id string, stat
|
|||||||
|
|
||||||
func (r *ketoOutboxRepository) MarkProcessed(ctx context.Context, id string) error {
|
func (r *ketoOutboxRepository) MarkProcessed(ctx context.Context, id string) error {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
return r.db.WithContext(ctx).Model(&domain.KetoOutbox{}).Where("id = ?", id).Updates(map[string]interface{}{
|
return r.db.WithContext(ctx).Model(&domain.KetoOutbox{}).Where("id = ?", id).Updates(map[string]any{
|
||||||
"status": domain.KetoOutboxStatusProcessed,
|
"status": domain.KetoOutboxStatusProcessed,
|
||||||
"processed_at": &now,
|
"processed_at": &now,
|
||||||
"updated_at": now,
|
"updated_at": now,
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ func (r *userProjectionRepository) CountTenantMembers(ctx context.Context, tenan
|
|||||||
}
|
}
|
||||||
|
|
||||||
valuePlaceholders := make([]string, 0, len(tenants))
|
valuePlaceholders := make([]string, 0, len(tenants))
|
||||||
args := make([]interface{}, 0, len(tenants)*2)
|
args := make([]any, 0, len(tenants)*2)
|
||||||
for _, tenant := range tenants {
|
for _, tenant := range tenants {
|
||||||
valuePlaceholders = append(valuePlaceholders, "(?, ?)")
|
valuePlaceholders = append(valuePlaceholders, "(?, ?)")
|
||||||
args = append(args, strings.TrimSpace(tenant.ID), strings.TrimSpace(tenant.Slug))
|
args = append(args, strings.TrimSpace(tenant.ID), strings.TrimSpace(tenant.Slug))
|
||||||
@@ -124,6 +124,14 @@ func (r *userProjectionRepository) ReplaceAllFromKratos(ctx context.Context, use
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(users) > 0 {
|
if len(users) > 0 {
|
||||||
|
// [FIX] Handle email conflicts before bulk upsert
|
||||||
|
for _, u := range users {
|
||||||
|
if u.Email != "" {
|
||||||
|
// Hard-delete any record with same email but different ID to clear unique constraint
|
||||||
|
_ = tx.Unscoped().Where("email = ? AND id != ?", u.Email, u.ID).Delete(&domain.User{}).Error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err := tx.Clauses(clause.OnConflict{
|
if err := tx.Clauses(clause.OnConflict{
|
||||||
Columns: []clause.Column{{Name: "id"}},
|
Columns: []clause.Column{{Name: "id"}},
|
||||||
UpdateAll: true,
|
UpdateAll: true,
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ type UserRepository interface {
|
|||||||
FindByTenantIDs(ctx context.Context, tenantIDs []string) ([]domain.User, error)
|
FindByTenantIDs(ctx context.Context, tenantIDs []string) ([]domain.User, error)
|
||||||
FindByCompanyCodes(ctx context.Context, codes []string) ([]domain.User, error)
|
FindByCompanyCodes(ctx context.Context, codes []string) ([]domain.User, error)
|
||||||
Delete(ctx context.Context, id string) error
|
Delete(ctx context.Context, id string) error
|
||||||
|
DB() *gorm.DB
|
||||||
|
|
||||||
// Multiple identifiers support
|
// Multiple identifiers support
|
||||||
UpdateUserLoginIDs(ctx context.Context, userID string, loginIDs []domain.UserLoginID) error
|
UpdateUserLoginIDs(ctx context.Context, userID string, loginIDs []domain.UserLoginID) error
|
||||||
@@ -40,18 +41,27 @@ func NewUserRepository(db *gorm.DB) UserRepository {
|
|||||||
return &userRepository{db: db}
|
return &userRepository{db: db}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *userRepository) DB() *gorm.DB {
|
||||||
|
return r.db
|
||||||
|
}
|
||||||
|
|
||||||
func (r *userRepository) Create(ctx context.Context, user *domain.User) error {
|
func (r *userRepository) Create(ctx context.Context, user *domain.User) error {
|
||||||
return r.db.WithContext(ctx).Create(user).Error
|
return r.db.WithContext(ctx).Create(user).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *userRepository) Update(ctx context.Context, user *domain.User) error {
|
func (r *userRepository) Update(ctx context.Context, user *domain.User) error {
|
||||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
// 1. Check for email collision (including soft-deleted)
|
||||||
var existing domain.User
|
var existing domain.User
|
||||||
if err := tx.Unscoped().Where("email = ?", user.Email).First(&existing).Error; err == nil {
|
if err := tx.Unscoped().Where("email = ?", user.Email).First(&existing).Error; err == nil {
|
||||||
|
// If email exists but ID is different, we MUST clear the old one to avoid unique constraint violation
|
||||||
if existing.ID != user.ID {
|
if existing.ID != user.ID {
|
||||||
|
// [Restored] Check if the existing user is archived
|
||||||
if strings.EqualFold(strings.TrimSpace(existing.Status), domain.UserStatusArchived) {
|
if strings.EqualFold(strings.TrimSpace(existing.Status), domain.UserStatusArchived) {
|
||||||
return fmt.Errorf("email is reserved by archived user: %s", user.Email)
|
return fmt.Errorf("email is reserved by archived user: %s", user.Email)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HARD DELETE the old record and its associated login IDs to free up the email and identifiers
|
||||||
if err := tx.Unscoped().Where("user_id = ?", existing.ID).Delete(&domain.UserLoginID{}).Error; err != nil {
|
if err := tx.Unscoped().Where("user_id = ?", existing.ID).Delete(&domain.UserLoginID{}).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -61,9 +71,25 @@ func (r *userRepository) Update(ctx context.Context, user *domain.User) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return tx.Clauses(clause.OnConflict{
|
// 2. Perform Upsert on the new/target ID
|
||||||
Columns: []clause.Column{{Name: "id"}},
|
return tx.Unscoped().Clauses(clause.OnConflict{
|
||||||
UpdateAll: true,
|
Columns: []clause.Column{{Name: "id"}},
|
||||||
|
DoUpdates: clause.Assignments(map[string]any{
|
||||||
|
"email": user.Email,
|
||||||
|
"name": user.Name,
|
||||||
|
"phone": user.Phone,
|
||||||
|
"role": user.Role,
|
||||||
|
"status": user.Status,
|
||||||
|
"department": user.Department,
|
||||||
|
"grade": user.Grade,
|
||||||
|
"position": user.Position,
|
||||||
|
"job_title": user.JobTitle,
|
||||||
|
"metadata": user.Metadata,
|
||||||
|
"tenant_id": user.TenantID,
|
||||||
|
"affiliation_type": user.AffiliationType,
|
||||||
|
"updated_at": user.UpdatedAt,
|
||||||
|
"deleted_at": nil, // Ensure it's active
|
||||||
|
}),
|
||||||
}).Create(user).Error
|
}).Create(user).Error
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -222,8 +248,9 @@ func (r *userRepository) Delete(ctx context.Context, id string) error {
|
|||||||
|
|
||||||
func (r *userRepository) UpdateUserLoginIDs(ctx context.Context, userID string, loginIDs []domain.UserLoginID) error {
|
func (r *userRepository) UpdateUserLoginIDs(ctx context.Context, userID string, loginIDs []domain.UserLoginID) error {
|
||||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
// Delete existing login IDs for this user
|
// [FIX] Use Unscoped to permanently delete existing login IDs for this user
|
||||||
if err := tx.Where("user_id = ?", userID).Delete(&domain.UserLoginID{}).Error; err != nil {
|
// This prevents unique constraint violations with soft-deleted records
|
||||||
|
if err := tx.Unscoped().Where("user_id = ?", userID).Delete(&domain.UserLoginID{}).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,16 +3,16 @@ package response
|
|||||||
import "github.com/gofiber/fiber/v2"
|
import "github.com/gofiber/fiber/v2"
|
||||||
|
|
||||||
type ErrorResponse struct {
|
type ErrorResponse struct {
|
||||||
Error string `json:"error"`
|
Error string `json:"error"`
|
||||||
Code string `json:"code,omitempty"`
|
Code string `json:"code,omitempty"`
|
||||||
Details interface{} `json:"details,omitempty"`
|
Details any `json:"details,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func Error(c *fiber.Ctx, status int, code, message string) error {
|
func Error(c *fiber.Ctx, status int, code, message string) error {
|
||||||
return ErrorWithDetails(c, status, code, message, nil)
|
return ErrorWithDetails(c, status, code, message, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func ErrorWithDetails(c *fiber.Ctx, status int, code, message string, details interface{}) error {
|
func ErrorWithDetails(c *fiber.Ctx, status int, code, message string, details any) error {
|
||||||
resp := ErrorResponse{
|
resp := ErrorResponse{
|
||||||
Error: message,
|
Error: message,
|
||||||
Code: code,
|
Code: code,
|
||||||
|
|||||||
@@ -36,13 +36,13 @@ func TestBackchannelLogoutService_BuildLogoutToken(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
var claims struct {
|
var claims struct {
|
||||||
Issuer string `json:"iss"`
|
Issuer string `json:"iss"`
|
||||||
Subject string `json:"sub"`
|
Subject string `json:"sub"`
|
||||||
Aud interface{} `json:"aud"`
|
Aud any `json:"aud"`
|
||||||
Iat int64 `json:"iat"`
|
Iat int64 `json:"iat"`
|
||||||
Jti string `json:"jti"`
|
Jti string `json:"jti"`
|
||||||
Sid string `json:"sid"`
|
Sid string `json:"sid"`
|
||||||
Events map[string]interface{} `json:"events"`
|
Events map[string]any `json:"events"`
|
||||||
}
|
}
|
||||||
require.NoError(t, parsed.Claims(jwks.Keys[0].Key, &claims))
|
require.NoError(t, parsed.Claims(jwks.Keys[0].Key, &claims))
|
||||||
|
|
||||||
@@ -51,7 +51,7 @@ func TestBackchannelLogoutService_BuildLogoutToken(t *testing.T) {
|
|||||||
switch aud := claims.Aud.(type) {
|
switch aud := claims.Aud.(type) {
|
||||||
case string:
|
case string:
|
||||||
assert.Equal(t, "client-1", aud)
|
assert.Equal(t, "client-1", aud)
|
||||||
case []interface{}:
|
case []any:
|
||||||
assert.Len(t, aud, 1)
|
assert.Len(t, aud, 1)
|
||||||
assert.Equal(t, "client-1", aud[0])
|
assert.Equal(t, "client-1", aud[0])
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -65,21 +65,21 @@ func (s *DeveloperService) ListRequests(ctx context.Context, userID, status stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *DeveloperService) ApproveRequest(ctx context.Context, id uint, adminNotes string) error {
|
func (s *DeveloperService) ApproveRequest(ctx context.Context, id uint, adminNotes string) error {
|
||||||
return s.db.WithContext(ctx).Model(&domain.DeveloperRequest{}).Where("id = ?", id).Updates(map[string]interface{}{
|
return s.db.WithContext(ctx).Model(&domain.DeveloperRequest{}).Where("id = ?", id).Updates(map[string]any{
|
||||||
"status": domain.DeveloperRequestStatusApproved,
|
"status": domain.DeveloperRequestStatusApproved,
|
||||||
"admin_notes": adminNotes,
|
"admin_notes": adminNotes,
|
||||||
}).Error
|
}).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *DeveloperService) RejectRequest(ctx context.Context, id uint, adminNotes string) error {
|
func (s *DeveloperService) RejectRequest(ctx context.Context, id uint, adminNotes string) error {
|
||||||
return s.db.WithContext(ctx).Model(&domain.DeveloperRequest{}).Where("id = ?", id).Updates(map[string]interface{}{
|
return s.db.WithContext(ctx).Model(&domain.DeveloperRequest{}).Where("id = ?", id).Updates(map[string]any{
|
||||||
"status": domain.DeveloperRequestStatusRejected,
|
"status": domain.DeveloperRequestStatusRejected,
|
||||||
"admin_notes": adminNotes,
|
"admin_notes": adminNotes,
|
||||||
}).Error
|
}).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *DeveloperService) CancelApprovedRequest(ctx context.Context, id uint, adminNotes string) error {
|
func (s *DeveloperService) CancelApprovedRequest(ctx context.Context, id uint, adminNotes string) error {
|
||||||
return s.db.WithContext(ctx).Model(&domain.DeveloperRequest{}).Where("id = ?", id).Updates(map[string]interface{}{
|
return s.db.WithContext(ctx).Model(&domain.DeveloperRequest{}).Where("id = ?", id).Updates(map[string]any{
|
||||||
"status": domain.DeveloperRequestStatusCancelled,
|
"status": domain.DeveloperRequestStatusCancelled,
|
||||||
"admin_notes": adminNotes,
|
"admin_notes": adminNotes,
|
||||||
}).Error
|
}).Error
|
||||||
|
|||||||
@@ -309,7 +309,7 @@ func (s *HeadlessJWKSCacheService) refreshClient(ctx context.Context, client dom
|
|||||||
updated := *previous
|
updated := *previous
|
||||||
updated.JWKSURI = jwksURI
|
updated.JWKSURI = jwksURI
|
||||||
updated.LastCheckedAt = &now
|
updated.LastCheckedAt = &now
|
||||||
updated.ExpiresAt = ptrTime(now.Add(s.TTL))
|
updated.ExpiresAt = new(now.Add(s.TTL))
|
||||||
updated.NextRetryAt = nil
|
updated.NextRetryAt = nil
|
||||||
updated.LastRefreshStatus = "success"
|
updated.LastRefreshStatus = "success"
|
||||||
updated.LastError = ""
|
updated.LastError = ""
|
||||||
@@ -339,7 +339,7 @@ func (s *HeadlessJWKSCacheService) refreshClient(ctx context.Context, client dom
|
|||||||
ClientID: client.ClientID,
|
ClientID: client.ClientID,
|
||||||
JWKSURI: jwksURI,
|
JWKSURI: jwksURI,
|
||||||
CachedAt: &now,
|
CachedAt: &now,
|
||||||
ExpiresAt: ptrTime(now.Add(s.TTL)),
|
ExpiresAt: new(now.Add(s.TTL)),
|
||||||
LastCheckedAt: &now,
|
LastCheckedAt: &now,
|
||||||
NextRetryAt: nil,
|
NextRetryAt: nil,
|
||||||
LastSuccessfulVerificationAt: previousLastVerification(previous),
|
LastSuccessfulVerificationAt: previousLastVerification(previous),
|
||||||
@@ -379,7 +379,7 @@ func (s *HeadlessJWKSCacheService) persistRefreshFailure(client domain.HydraClie
|
|||||||
state.ConsecutiveFailures = previous.ConsecutiveFailures + 1
|
state.ConsecutiveFailures = previous.ConsecutiveFailures + 1
|
||||||
}
|
}
|
||||||
if s.shouldBackoff(state.ConsecutiveFailures) {
|
if s.shouldBackoff(state.ConsecutiveFailures) {
|
||||||
state.NextRetryAt = ptrTime(now.Add(s.failureBackoffDuration()))
|
state.NextRetryAt = new(now.Add(s.failureBackoffDuration()))
|
||||||
}
|
}
|
||||||
_ = s.SaveState(client.ClientID, state)
|
_ = s.SaveState(client.ClientID, state)
|
||||||
return &state
|
return &state
|
||||||
@@ -480,8 +480,9 @@ func previousLastVerification(previous *domain.HeadlessJWKSCacheState) *time.Tim
|
|||||||
return previous.LastSuccessfulVerificationAt
|
return previous.LastSuccessfulVerificationAt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//go:fix inline
|
||||||
func ptrTime(value time.Time) *time.Time {
|
func ptrTime(value time.Time) *time.Time {
|
||||||
return &value
|
return new(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *HeadlessJWKSCacheWorker) Start(ctx context.Context) {
|
func (w *HeadlessJWKSCacheWorker) Start(ctx context.Context) {
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ func TestHeadlessJWKSCacheService_EnsureFreshKeySet_UsesCachedJWKSWhenFresh(t *t
|
|||||||
CachedKids: []string{"cached-key"},
|
CachedKids: []string{"cached-key"},
|
||||||
CachedAt: &now,
|
CachedAt: &now,
|
||||||
LastCheckedAt: &now,
|
LastCheckedAt: &now,
|
||||||
ExpiresAt: ptrTestTime(now.Add(30 * time.Minute)),
|
ExpiresAt: new(now.Add(30 * time.Minute)),
|
||||||
LastRefreshStatus: "success",
|
LastRefreshStatus: "success",
|
||||||
ConsecutiveFailures: 0,
|
ConsecutiveFailures: 0,
|
||||||
})
|
})
|
||||||
@@ -114,7 +114,7 @@ func TestHeadlessJWKSCacheService_EnsureFreshKeySet_RefreshesWhenKidMissing(t *t
|
|||||||
CachedKids: []string{"stale-key"},
|
CachedKids: []string{"stale-key"},
|
||||||
CachedAt: &now,
|
CachedAt: &now,
|
||||||
LastCheckedAt: &now,
|
LastCheckedAt: &now,
|
||||||
ExpiresAt: ptrTestTime(now.Add(30 * time.Minute)),
|
ExpiresAt: new(now.Add(30 * time.Minute)),
|
||||||
LastRefreshStatus: "success",
|
LastRefreshStatus: "success",
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -181,7 +181,7 @@ func TestHeadlessJWKSCacheService_ShouldPrefetch_SkipsUntilNextRetryAt(t *testin
|
|||||||
ClientID: "client-headless",
|
ClientID: "client-headless",
|
||||||
LastRefreshStatus: "failure",
|
LastRefreshStatus: "failure",
|
||||||
ConsecutiveFailures: 3,
|
ConsecutiveFailures: 3,
|
||||||
NextRetryAt: ptrTestTime(now.Add(10 * time.Minute)),
|
NextRetryAt: new(now.Add(10 * time.Minute)),
|
||||||
}
|
}
|
||||||
|
|
||||||
assert.False(t, cacheService.ShouldPrefetch(state, now))
|
assert.False(t, cacheService.ShouldPrefetch(state, now))
|
||||||
@@ -216,7 +216,7 @@ func TestHeadlessJWKSCacheWorker_RunOnce_SkipsBackoffTargets(t *testing.T) {
|
|||||||
JWKSURI: clients[1].HeadlessJWKSURI(),
|
JWKSURI: clients[1].HeadlessJWKSURI(),
|
||||||
LastRefreshStatus: "failure",
|
LastRefreshStatus: "failure",
|
||||||
ConsecutiveFailures: 3,
|
ConsecutiveFailures: 3,
|
||||||
NextRetryAt: ptrTestTime(now.Add(10 * time.Minute)),
|
NextRetryAt: new(now.Add(10 * time.Minute)),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
fetchCounts := map[string]int{}
|
fetchCounts := map[string]int{}
|
||||||
@@ -278,7 +278,7 @@ func TestHeadlessJWKSCacheWorker_RunOnce_RetriesAfterBackoffAndClearsFailureStat
|
|||||||
LastRefreshStatus: "failure",
|
LastRefreshStatus: "failure",
|
||||||
LastError: "previous failure",
|
LastError: "previous failure",
|
||||||
ConsecutiveFailures: 3,
|
ConsecutiveFailures: 3,
|
||||||
NextRetryAt: ptrTestTime(time.Now().Add(-time.Minute)),
|
NextRetryAt: new(time.Now().Add(-time.Minute)),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
fetchCount := 0
|
fetchCount := 0
|
||||||
@@ -353,7 +353,7 @@ func TestHeadlessJWKSCacheWorker_RunOnce_MixedClients(t *testing.T) {
|
|||||||
JWKSURI: skipClient.HeadlessJWKSURI(),
|
JWKSURI: skipClient.HeadlessJWKSURI(),
|
||||||
LastRefreshStatus: "failure",
|
LastRefreshStatus: "failure",
|
||||||
ConsecutiveFailures: 3,
|
ConsecutiveFailures: 3,
|
||||||
NextRetryAt: ptrTestTime(time.Now().Add(10 * time.Minute)),
|
NextRetryAt: new(time.Now().Add(10 * time.Minute)),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
fetchCounts := map[string]int{}
|
fetchCounts := map[string]int{}
|
||||||
@@ -416,8 +416,9 @@ func mustServiceHeadlessRSAJWK(t *testing.T, kid string) (*rsa.PrivateKey, jose.
|
|||||||
return privateKey, jose.JSONWebKeySet{Keys: []jose.JSONWebKey{publicJWK}}
|
return privateKey, jose.JSONWebKeySet{Keys: []jose.JSONWebKey{publicJWK}}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//go:fix inline
|
||||||
func ptrTestTime(value time.Time) *time.Time {
|
func ptrTestTime(value time.Time) *time.Time {
|
||||||
return &value
|
return new(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
func newTestHeadlessClient(clientID, jwksURI string) domain.HydraClient {
|
func newTestHeadlessClient(clientID, jwksURI string) domain.HydraClient {
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ func (s *HydraAdminService) GetClient(ctx context.Context, clientID string) (*do
|
|||||||
|
|
||||||
func (s *HydraAdminService) PatchClientStatus(ctx context.Context, clientID, status string) (*domain.HydraClient, error) {
|
func (s *HydraAdminService) PatchClientStatus(ctx context.Context, clientID, status string) (*domain.HydraClient, error) {
|
||||||
// JSON Patch format
|
// JSON Patch format
|
||||||
payload := []map[string]interface{}{
|
payload := []map[string]any{
|
||||||
{
|
{
|
||||||
"op": "replace",
|
"op": "replace",
|
||||||
"path": "/metadata/status",
|
"path": "/metadata/status",
|
||||||
@@ -396,7 +396,7 @@ func (s *HydraAdminService) RejectConsentRequest(ctx context.Context, challenge
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
payload := map[string]interface{}{
|
payload := map[string]any{
|
||||||
"error": "access_denied",
|
"error": "access_denied",
|
||||||
"error_description": "The user decided to reject the consent request.",
|
"error_description": "The user decided to reject the consent request.",
|
||||||
}
|
}
|
||||||
@@ -438,7 +438,7 @@ func (s *HydraAdminService) RejectLoginRequest(ctx context.Context, challenge, e
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
payload := map[string]interface{}{
|
payload := map[string]any{
|
||||||
"error": error,
|
"error": error,
|
||||||
"error_description": errorDescription,
|
"error_description": errorDescription,
|
||||||
}
|
}
|
||||||
@@ -513,7 +513,7 @@ func (s *HydraAdminService) AcceptConsentRequest(ctx context.Context, challenge
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
payload := map[string]interface{}{
|
payload := map[string]any{
|
||||||
"grant_scope": grantInfo.RequestedScope,
|
"grant_scope": grantInfo.RequestedScope,
|
||||||
"grant_audience": grantInfo.RequestedAudience,
|
"grant_audience": grantInfo.RequestedAudience,
|
||||||
"remember": true,
|
"remember": true,
|
||||||
@@ -564,7 +564,7 @@ func (s *HydraAdminService) AcceptLoginRequest(ctx context.Context, challenge st
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
payload := map[string]interface{}{
|
payload := map[string]any{
|
||||||
"subject": subject,
|
"subject": subject,
|
||||||
"remember": true,
|
"remember": true,
|
||||||
"remember_for": 2592000,
|
"remember_for": 2592000,
|
||||||
@@ -600,13 +600,13 @@ func (s *HydraAdminService) AcceptLoginRequest(ctx context.Context, challenge st
|
|||||||
}
|
}
|
||||||
|
|
||||||
type HydraIntrospectionResponse struct {
|
type HydraIntrospectionResponse struct {
|
||||||
Active bool `json:"active"`
|
Active bool `json:"active"`
|
||||||
Subject string `json:"sub"`
|
Subject string `json:"sub"`
|
||||||
ClientID string `json:"client_id"`
|
ClientID string `json:"client_id"`
|
||||||
Scope string `json:"scope"`
|
Scope string `json:"scope"`
|
||||||
ExpiresAt int64 `json:"exp"`
|
ExpiresAt int64 `json:"exp"`
|
||||||
IssuedAt int64 `json:"iat"`
|
IssuedAt int64 `json:"iat"`
|
||||||
Ext map[string]interface{} `json:"ext"`
|
Ext map[string]any `json:"ext"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *HydraAdminService) IntrospectToken(ctx context.Context, token string) (*HydraIntrospectionResponse, error) {
|
func (s *HydraAdminService) IntrospectToken(ctx context.Context, token string) (*HydraIntrospectionResponse, error) {
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ func TestHydraAdminService_PatchClientStatus(t *testing.T) {
|
|||||||
assert.Equal(t, "PATCH", r.Method)
|
assert.Equal(t, "PATCH", r.Method)
|
||||||
assert.Equal(t, "application/json-patch+json", r.Header.Get("Content-Type"))
|
assert.Equal(t, "application/json-patch+json", r.Header.Get("Content-Type"))
|
||||||
|
|
||||||
var payload []map[string]interface{}
|
var payload []map[string]any
|
||||||
_ = json.NewDecoder(r.Body).Decode(&payload)
|
_ = json.NewDecoder(r.Body).Decode(&payload)
|
||||||
assert.Equal(t, "replace", payload[0]["op"])
|
assert.Equal(t, "replace", payload[0]["op"])
|
||||||
assert.Equal(t, "/metadata/status", payload[0]["path"])
|
assert.Equal(t, "/metadata/status", payload[0]["path"])
|
||||||
@@ -273,7 +273,7 @@ func TestHydraAdminService_AcceptLoginRequest(t *testing.T) {
|
|||||||
assert.Equal(t, "/oauth2/auth/requests/login/accept", r.URL.Path)
|
assert.Equal(t, "/oauth2/auth/requests/login/accept", r.URL.Path)
|
||||||
assert.Equal(t, challenge, r.URL.Query().Get("login_challenge"))
|
assert.Equal(t, challenge, r.URL.Query().Get("login_challenge"))
|
||||||
|
|
||||||
var body map[string]interface{}
|
var body map[string]any
|
||||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||||
assert.Equal(t, subject, body["subject"])
|
assert.Equal(t, subject, body["subject"])
|
||||||
|
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ func (s *ketoService) CheckPermission(ctx context.Context, subject, namespace, o
|
|||||||
maxRetries := 5
|
maxRetries := 5
|
||||||
backoff := 200 * time.Millisecond
|
backoff := 200 * time.Millisecond
|
||||||
|
|
||||||
for i := 0; i < maxRetries; i++ {
|
for i := range maxRetries {
|
||||||
req, _ := http.NewRequestWithContext(ctx, "GET", u.String(), nil)
|
req, _ := http.NewRequestWithContext(ctx, "GET", u.String(), nil)
|
||||||
resp, err := s.client.Do(req)
|
resp, err := s.client.Do(req)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -143,7 +143,7 @@ func (s *ketoService) CheckPermission(ctx context.Context, subject, namespace, o
|
|||||||
|
|
||||||
func (s *ketoService) CreateRelation(ctx context.Context, namespace, object, relation, subject string) error {
|
func (s *ketoService) CreateRelation(ctx context.Context, namespace, object, relation, subject string) error {
|
||||||
u := fmt.Sprintf("%s/admin/relation-tuples", s.writeURL)
|
u := fmt.Sprintf("%s/admin/relation-tuples", s.writeURL)
|
||||||
payload := map[string]interface{}{
|
payload := map[string]any{
|
||||||
"namespace": namespace,
|
"namespace": namespace,
|
||||||
"object": object,
|
"object": object,
|
||||||
"relation": relation,
|
"relation": relation,
|
||||||
@@ -156,7 +156,7 @@ func (s *ketoService) CreateRelation(ctx context.Context, namespace, object, rel
|
|||||||
maxRetries := 5
|
maxRetries := 5
|
||||||
backoff := 200 * time.Millisecond
|
backoff := 200 * time.Millisecond
|
||||||
|
|
||||||
for i := 0; i < maxRetries; i++ {
|
for i := range maxRetries {
|
||||||
req, _ := http.NewRequestWithContext(ctx, "PUT", u, bytes.NewReader(body))
|
req, _ := http.NewRequestWithContext(ctx, "PUT", u, bytes.NewReader(body))
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
@@ -197,7 +197,7 @@ func (s *ketoService) DeleteRelation(ctx context.Context, namespace, object, rel
|
|||||||
maxRetries := 5
|
maxRetries := 5
|
||||||
backoff := 200 * time.Millisecond
|
backoff := 200 * time.Millisecond
|
||||||
|
|
||||||
for i := 0; i < maxRetries; i++ {
|
for i := range maxRetries {
|
||||||
req, _ := http.NewRequestWithContext(ctx, "DELETE", u.String(), nil)
|
req, _ := http.NewRequestWithContext(ctx, "DELETE", u.String(), nil)
|
||||||
resp, err := s.client.Do(req)
|
resp, err := s.client.Do(req)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ func TestKetoService_CreateRelation(t *testing.T) {
|
|||||||
assert.Equal(t, "/admin/relation-tuples", r.URL.Path)
|
assert.Equal(t, "/admin/relation-tuples", r.URL.Path)
|
||||||
assert.Equal(t, "PUT", r.Method)
|
assert.Equal(t, "PUT", r.Method)
|
||||||
|
|
||||||
var body map[string]interface{}
|
var body map[string]any
|
||||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||||
assert.Equal(t, "tenants", body["namespace"])
|
assert.Equal(t, "tenants", body["namespace"])
|
||||||
assert.Equal(t, "tenant1", body["object"])
|
assert.Equal(t, "tenant1", body["object"])
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -17,15 +18,15 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type KratosIdentity struct {
|
type KratosIdentity struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
SchemaID string `json:"schema_id,omitempty"`
|
SchemaID string `json:"schema_id,omitempty"`
|
||||||
Traits map[string]interface{} `json:"traits"`
|
Traits map[string]any `json:"traits"`
|
||||||
State string `json:"state,omitempty"`
|
State string `json:"state,omitempty"`
|
||||||
MetadataAdmin interface{} `json:"metadata_admin,omitempty"`
|
MetadataAdmin any `json:"metadata_admin,omitempty"`
|
||||||
MetadataPublic interface{} `json:"metadata_public,omitempty"`
|
MetadataPublic any `json:"metadata_public,omitempty"`
|
||||||
ExternalID string `json:"external_id,omitempty"`
|
ExternalID string `json:"external_id,omitempty"`
|
||||||
CreatedAt time.Time `json:"created_at,omitempty"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type KratosSessionDevice struct {
|
type KratosSessionDevice struct {
|
||||||
@@ -36,9 +37,9 @@ type KratosSessionDevice struct {
|
|||||||
type KratosSession struct {
|
type KratosSession struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Active bool `json:"active"`
|
Active bool `json:"active"`
|
||||||
AuthenticatedAt time.Time `json:"authenticated_at,omitempty"`
|
AuthenticatedAt time.Time `json:"authenticated_at"`
|
||||||
ExpiresAt time.Time `json:"expires_at,omitempty"`
|
ExpiresAt time.Time `json:"expires_at"`
|
||||||
IssuedAt time.Time `json:"issued_at,omitempty"`
|
IssuedAt time.Time `json:"issued_at"`
|
||||||
Identity *KratosIdentity `json:"identity,omitempty"`
|
Identity *KratosIdentity `json:"identity,omitempty"`
|
||||||
Devices []KratosSessionDevice `json:"devices,omitempty"`
|
Devices []KratosSessionDevice `json:"devices,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -47,7 +48,7 @@ type KratosAdminService interface {
|
|||||||
ListIdentities(ctx context.Context) ([]KratosIdentity, error)
|
ListIdentities(ctx context.Context) ([]KratosIdentity, error)
|
||||||
FindIdentityIDByIdentifier(ctx context.Context, identifier string) (string, error)
|
FindIdentityIDByIdentifier(ctx context.Context, identifier string) (string, error)
|
||||||
GetIdentity(ctx context.Context, identityID string) (*KratosIdentity, error)
|
GetIdentity(ctx context.Context, identityID string) (*KratosIdentity, error)
|
||||||
UpdateIdentity(ctx context.Context, identityID string, traits map[string]interface{}, state string) (*KratosIdentity, error)
|
UpdateIdentity(ctx context.Context, identityID string, traits map[string]any, state string) (*KratosIdentity, error)
|
||||||
UpdateIdentityPassword(ctx context.Context, identityID, newPassword string) error
|
UpdateIdentityPassword(ctx context.Context, identityID, newPassword string) error
|
||||||
DeleteIdentity(ctx context.Context, identityID string) error
|
DeleteIdentity(ctx context.Context, identityID string) error
|
||||||
CreateUser(ctx context.Context, user *domain.BrokerUser, password string) (string, error)
|
CreateUser(ctx context.Context, user *domain.BrokerUser, password string) (string, error)
|
||||||
@@ -99,13 +100,81 @@ func (s *kratosAdminService) FindIdentityIDByIdentifier(ctx context.Context, ide
|
|||||||
}
|
}
|
||||||
|
|
||||||
endpoint := strings.TrimRight(s.AdminURL, "/") + "/admin/identities"
|
endpoint := strings.TrimRight(s.AdminURL, "/") + "/admin/identities"
|
||||||
|
|
||||||
|
// 1. Try credentials_identifier (Email/LoginID/Phone)
|
||||||
|
id, err := s.searchIdentities(ctx, endpoint, "credentials_identifier", identifier)
|
||||||
|
if err == nil && id != "" {
|
||||||
|
// VERIFY: Kratos sometimes ignores unknown query params and returns the first identity.
|
||||||
|
if s.verifyIdentityMatch(ctx, id, identifier) {
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. If it looks like a UUID, try external_id
|
||||||
|
if matched, _ := regexp.MatchString(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`, strings.ToLower(identifier)); matched {
|
||||||
|
id, err = s.searchIdentities(ctx, endpoint, "external_id", identifier)
|
||||||
|
if err == nil && id != "" {
|
||||||
|
if s.verifyIdentityMatch(ctx, id, identifier) {
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Also try direct ID lookup
|
||||||
|
identity, err := s.GetIdentity(ctx, identifier)
|
||||||
|
if err == nil && identity != nil {
|
||||||
|
return identity.ID, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *kratosAdminService) verifyIdentityMatch(ctx context.Context, id, identifier string) bool {
|
||||||
|
identity, err := s.GetIdentity(ctx, id)
|
||||||
|
if err != nil || identity == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exact ID match
|
||||||
|
if strings.EqualFold(identity.ID, identifier) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// Exact ExternalID match
|
||||||
|
if strings.EqualFold(identity.ExternalID, identifier) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// Check traits (Email, CustomLoginIDs)
|
||||||
|
if email, ok := identity.Traits["email"].(string); ok && strings.EqualFold(email, identifier) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if phone, ok := identity.Traits["phone_number"].(string); ok && strings.EqualFold(phone, identifier) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if lids, ok := identity.Traits["custom_login_ids"].([]any); ok {
|
||||||
|
for _, lid := range lids {
|
||||||
|
if s, ok := lid.(string); ok && strings.EqualFold(s, identifier) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if lids, ok := identity.Traits["custom_login_ids"].([]string); ok {
|
||||||
|
for _, lid := range lids {
|
||||||
|
if strings.EqualFold(lid, identifier) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *kratosAdminService) searchIdentities(ctx context.Context, endpoint, key, value string) (string, error) {
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
query := req.URL.Query()
|
query := req.URL.Query()
|
||||||
query.Set("credentials_identifier", identifier)
|
query.Set(key, value)
|
||||||
req.URL.RawQuery = query.Encode()
|
req.URL.RawQuery = query.Encode()
|
||||||
|
|
||||||
resp, err := s.httpClient().Do(req)
|
resp, err := s.httpClient().Do(req)
|
||||||
@@ -119,7 +188,7 @@ func (s *kratosAdminService) FindIdentityIDByIdentifier(ctx context.Context, ide
|
|||||||
}
|
}
|
||||||
if resp.StatusCode >= 300 {
|
if resp.StatusCode >= 300 {
|
||||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||||
return "", fmt.Errorf("kratos admin search failed status=%d body=%s", resp.StatusCode, string(body))
|
return "", fmt.Errorf("kratos admin search by %s failed status=%d body=%s", key, resp.StatusCode, string(body))
|
||||||
}
|
}
|
||||||
|
|
||||||
var identities []struct {
|
var identities []struct {
|
||||||
@@ -162,8 +231,8 @@ func (s *kratosAdminService) GetIdentity(ctx context.Context, identityID string)
|
|||||||
return &identity, nil
|
return &identity, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *kratosAdminService) UpdateIdentity(ctx context.Context, identityID string, traits map[string]interface{}, state string) (*KratosIdentity, error) {
|
func (s *kratosAdminService) UpdateIdentity(ctx context.Context, identityID string, traits map[string]any, state string) (*KratosIdentity, error) {
|
||||||
payload := map[string]interface{}{
|
payload := map[string]any{
|
||||||
"schema_id": "default",
|
"schema_id": "default",
|
||||||
"traits": traits,
|
"traits": traits,
|
||||||
}
|
}
|
||||||
@@ -211,12 +280,12 @@ func (s *kratosAdminService) UpdateIdentityPassword(ctx context.Context, identit
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
payload := map[string]interface{}{
|
payload := map[string]any{
|
||||||
"schema_id": identity.SchemaID,
|
"schema_id": identity.SchemaID,
|
||||||
"traits": identity.Traits,
|
"traits": identity.Traits,
|
||||||
"state": identity.State,
|
"state": identity.State,
|
||||||
"credentials": map[string]interface{}{
|
"credentials": map[string]any{
|
||||||
"password": map[string]interface{}{
|
"password": map[string]any{
|
||||||
"config": map[string]string{
|
"config": map[string]string{
|
||||||
"hashed_password": hashedPassword,
|
"hashed_password": hashedPassword,
|
||||||
},
|
},
|
||||||
@@ -264,7 +333,7 @@ func (s *kratosAdminService) CreateUser(ctx context.Context, user *domain.Broker
|
|||||||
return "", fmt.Errorf("kratos admin: user payload is nil")
|
return "", fmt.Errorf("kratos admin: user payload is nil")
|
||||||
}
|
}
|
||||||
|
|
||||||
traits := map[string]interface{}{
|
traits := map[string]any{
|
||||||
"email": user.Email,
|
"email": user.Email,
|
||||||
"name": user.Name,
|
"name": user.Name,
|
||||||
}
|
}
|
||||||
@@ -278,11 +347,11 @@ func (s *kratosAdminService) CreateUser(ctx context.Context, user *domain.Broker
|
|||||||
traits[k] = v
|
traits[k] = v
|
||||||
}
|
}
|
||||||
|
|
||||||
payload := map[string]interface{}{
|
payload := map[string]any{
|
||||||
"schema_id": "default",
|
"schema_id": "default",
|
||||||
"traits": traits,
|
"traits": traits,
|
||||||
"credentials": map[string]interface{}{
|
"credentials": map[string]any{
|
||||||
"password": map[string]interface{}{
|
"password": map[string]any{
|
||||||
"config": map[string]string{
|
"config": map[string]string{
|
||||||
"password": password,
|
"password": password,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ func (m *MockKratosAdminServiceShared) GetIdentity(ctx context.Context, identity
|
|||||||
return args.Get(0).(*KratosIdentity), args.Error(1)
|
return args.Get(0).(*KratosIdentity), args.Error(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockKratosAdminServiceShared) UpdateIdentity(ctx context.Context, identityID string, traits map[string]interface{}, state string) (*KratosIdentity, error) {
|
func (m *MockKratosAdminServiceShared) UpdateIdentity(ctx context.Context, identityID string, traits map[string]any, state string) (*KratosIdentity, error) {
|
||||||
args := m.Called(ctx, identityID, traits, state)
|
args := m.Called(ctx, identityID, traits, state)
|
||||||
if args.Get(0) == nil {
|
if args.Get(0) == nil {
|
||||||
return nil, args.Error(1)
|
return nil, args.Error(1)
|
||||||
|
|||||||
@@ -59,6 +59,13 @@ func (o *OryProvider) CreateUser(user *domain.BrokerUser, password string) (stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 중복 확인
|
// 중복 확인
|
||||||
|
if user.ID != "" {
|
||||||
|
existing, err := o.getIdentity(user.ID)
|
||||||
|
if err == nil && existing != nil {
|
||||||
|
return "", fmt.Errorf("ory provider: identity already exists for uuid=%s", user.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
existingID, err := o.findIdentityID(user.Email)
|
existingID, err := o.findIdentityID(user.Email)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("ory provider: search identity failed: %w", err)
|
return "", fmt.Errorf("ory provider: search identity failed: %w", err)
|
||||||
@@ -102,7 +109,7 @@ func (o *OryProvider) CreateUser(user *domain.BrokerUser, password string) (stri
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
traits := map[string]interface{}{
|
traits := map[string]any{
|
||||||
"email": user.Email,
|
"email": user.Email,
|
||||||
"name": user.Name,
|
"name": user.Name,
|
||||||
}
|
}
|
||||||
@@ -123,18 +130,26 @@ func (o *OryProvider) CreateUser(user *domain.BrokerUser, password string) (stri
|
|||||||
traits[k] = v
|
traits[k] = v
|
||||||
}
|
}
|
||||||
|
|
||||||
payload := map[string]interface{}{
|
payload := map[string]any{
|
||||||
"schema_id": "default",
|
"schema_id": "default",
|
||||||
"traits": traits,
|
"traits": traits,
|
||||||
"credentials": map[string]interface{}{
|
"credentials": map[string]any{
|
||||||
"password": map[string]interface{}{
|
"password": map[string]any{
|
||||||
"config": map[string]string{
|
"config": map[string]string{
|
||||||
"password": password,
|
"password": password,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
verifiable := []map[string]interface{}{
|
if user.ID != "" {
|
||||||
|
// Use external_id as a fallback/mapping for the requested ID
|
||||||
|
payload["external_id"] = user.ID
|
||||||
|
// Also store in metadata_admin for audit/migration purposes
|
||||||
|
payload["metadata_admin"] = map[string]any{
|
||||||
|
"original_uuid": user.ID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
verifiable := []map[string]any{
|
||||||
{
|
{
|
||||||
"value": user.Email,
|
"value": user.Email,
|
||||||
"verified": true,
|
"verified": true,
|
||||||
@@ -142,14 +157,14 @@ func (o *OryProvider) CreateUser(user *domain.BrokerUser, password string) (stri
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
if user.PhoneNumber != "" {
|
if user.PhoneNumber != "" {
|
||||||
verifiable = append(verifiable, map[string]interface{}{
|
verifiable = append(verifiable, map[string]any{
|
||||||
"value": user.PhoneNumber,
|
"value": user.PhoneNumber,
|
||||||
"verified": true,
|
"verified": true,
|
||||||
"via": "sms",
|
"via": "sms",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
payload["verifiable_addresses"] = verifiable
|
payload["verifiable_addresses"] = verifiable
|
||||||
payload["recovery_addresses"] = []map[string]interface{}{
|
payload["recovery_addresses"] = []map[string]any{
|
||||||
{
|
{
|
||||||
"value": user.Email,
|
"value": user.Email,
|
||||||
"via": "email",
|
"via": "email",
|
||||||
@@ -454,12 +469,12 @@ func (o *OryProvider) ensureCodeLoginIdentifier(loginID string) error {
|
|||||||
existingIndex = idx
|
existingIndex = idx
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ops := make([]map[string]interface{}, 0, 2)
|
ops := make([]map[string]any, 0, 2)
|
||||||
if !exists {
|
if !exists {
|
||||||
ops = append(ops, map[string]interface{}{
|
ops = append(ops, map[string]any{
|
||||||
"op": "add",
|
"op": "add",
|
||||||
"path": "/verifiable_addresses/-",
|
"path": "/verifiable_addresses/-",
|
||||||
"value": map[string]interface{}{
|
"value": map[string]any{
|
||||||
"value": loginID,
|
"value": loginID,
|
||||||
"via": via,
|
"via": via,
|
||||||
"verified": true,
|
"verified": true,
|
||||||
@@ -469,14 +484,14 @@ func (o *OryProvider) ensureCodeLoginIdentifier(loginID string) error {
|
|||||||
} else {
|
} else {
|
||||||
addr := identity.VerifiableAddresses[existingIndex]
|
addr := identity.VerifiableAddresses[existingIndex]
|
||||||
if !addr.Verified {
|
if !addr.Verified {
|
||||||
ops = append(ops, map[string]interface{}{
|
ops = append(ops, map[string]any{
|
||||||
"op": "replace",
|
"op": "replace",
|
||||||
"path": fmt.Sprintf("/verifiable_addresses/%d/verified", existingIndex),
|
"path": fmt.Sprintf("/verifiable_addresses/%d/verified", existingIndex),
|
||||||
"value": true,
|
"value": true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if addr.Status != "" && addr.Status != "completed" {
|
if addr.Status != "" && addr.Status != "completed" {
|
||||||
ops = append(ops, map[string]interface{}{
|
ops = append(ops, map[string]any{
|
||||||
"op": "replace",
|
"op": "replace",
|
||||||
"path": fmt.Sprintf("/verifiable_addresses/%d/status", existingIndex),
|
"path": fmt.Sprintf("/verifiable_addresses/%d/status", existingIndex),
|
||||||
"value": "completed",
|
"value": "completed",
|
||||||
@@ -520,7 +535,7 @@ func (o *OryProvider) ensureCodeLoginIdentifier(loginID string) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
payload := map[string]interface{}{
|
payload := map[string]any{
|
||||||
"schema_id": fullIdentity.SchemaID,
|
"schema_id": fullIdentity.SchemaID,
|
||||||
"traits": fullIdentity.Traits,
|
"traits": fullIdentity.Traits,
|
||||||
"verifiable_addresses": addresses,
|
"verifiable_addresses": addresses,
|
||||||
@@ -561,12 +576,12 @@ type kratosRecoveryAddress struct {
|
|||||||
|
|
||||||
type kratosIdentityFull struct {
|
type kratosIdentityFull struct {
|
||||||
SchemaID string `json:"schema_id"`
|
SchemaID string `json:"schema_id"`
|
||||||
Traits map[string]interface{} `json:"traits"`
|
Traits map[string]any `json:"traits"`
|
||||||
VerifiableAddresses []kratosVerifiableAddress `json:"verifiable_addresses"`
|
VerifiableAddresses []kratosVerifiableAddress `json:"verifiable_addresses"`
|
||||||
RecoveryAddresses []kratosRecoveryAddress `json:"recovery_addresses"`
|
RecoveryAddresses []kratosRecoveryAddress `json:"recovery_addresses"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (o *OryProvider) patchIdentity(identityID string, ops []map[string]interface{}) error {
|
func (o *OryProvider) patchIdentity(identityID string, ops []map[string]any) error {
|
||||||
body, _ := json.Marshal(ops)
|
body, _ := json.Marshal(ops)
|
||||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodPatch, fmt.Sprintf("%s/admin/identities/%s", o.KratosAdminURL, identityID), bytes.NewReader(body))
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodPatch, fmt.Sprintf("%s/admin/identities/%s", o.KratosAdminURL, identityID), bytes.NewReader(body))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -750,12 +765,12 @@ func (o *OryProvider) UpdateUserPassword(loginID, newPassword string, r *http.Re
|
|||||||
return fmt.Errorf("ory provider: hash password failed: %w", err)
|
return fmt.Errorf("ory provider: hash password failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
payload := map[string]interface{}{
|
payload := map[string]any{
|
||||||
"schema_id": identity.SchemaID,
|
"schema_id": identity.SchemaID,
|
||||||
"traits": identity.Traits,
|
"traits": identity.Traits,
|
||||||
"state": identity.State,
|
"state": identity.State,
|
||||||
"credentials": map[string]interface{}{
|
"credentials": map[string]any{
|
||||||
"password": map[string]interface{}{
|
"password": map[string]any{
|
||||||
"config": map[string]string{
|
"config": map[string]string{
|
||||||
"hashed_password": hashedPassword,
|
"hashed_password": hashedPassword,
|
||||||
},
|
},
|
||||||
@@ -806,6 +821,57 @@ func getenv(key, fallback string) string {
|
|||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// findIdentityByID: Kratos Admin API에서 ID(UUID)로 직접 조회
|
||||||
|
func (o *OryProvider) findIdentityByID(id string) (string, error) {
|
||||||
|
identity, err := o.getIdentity(id)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if identity != nil {
|
||||||
|
return identity.ID, nil
|
||||||
|
}
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// findIdentityByExternalID: external_id 필드로 identity 검색
|
||||||
|
func (o *OryProvider) findIdentityByExternalID(externalID string) (string, error) {
|
||||||
|
u, err := url.Parse(fmt.Sprintf("%s/admin/identities", o.KratosAdminURL))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
query := u.Query()
|
||||||
|
// Kratos v1.1+ supports filtering by external_id
|
||||||
|
query.Set("external_id", externalID)
|
||||||
|
u.RawQuery = query.Encode()
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, u.String(), nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := o.httpClient().Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode >= 300 {
|
||||||
|
return "", nil // Ignore errors for search
|
||||||
|
}
|
||||||
|
|
||||||
|
var identities []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&identities); err != nil {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
if len(identities) == 0 {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
return identities[0].ID, nil
|
||||||
|
}
|
||||||
|
|
||||||
// findIdentityID: Kratos Admin API에서 credentials_identifier로 검색 후 첫 번째 identity id 반환
|
// findIdentityID: Kratos Admin API에서 credentials_identifier로 검색 후 첫 번째 identity id 반환
|
||||||
func (o *OryProvider) findIdentityID(loginID string) (string, error) {
|
func (o *OryProvider) findIdentityID(loginID string) (string, error) {
|
||||||
u, err := url.Parse(fmt.Sprintf("%s/admin/identities", o.KratosAdminURL))
|
u, err := url.Parse(fmt.Sprintf("%s/admin/identities", o.KratosAdminURL))
|
||||||
@@ -837,7 +903,8 @@ func (o *OryProvider) findIdentityID(loginID string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var identities []struct {
|
var identities []struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
|
Traits map[string]any `json:"traits"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&identities); err != nil {
|
if err := json.NewDecoder(resp.Body).Decode(&identities); err != nil {
|
||||||
return "", fmt.Errorf("decode response failed: %w", err)
|
return "", fmt.Errorf("decode response failed: %w", err)
|
||||||
@@ -845,7 +912,30 @@ func (o *OryProvider) findIdentityID(loginID string) (string, error) {
|
|||||||
if len(identities) == 0 {
|
if len(identities) == 0 {
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
return identities[0].ID, nil
|
|
||||||
|
// VERIFY: Double check traits to avoid Kratos ignoring the query param
|
||||||
|
candidate := identities[0]
|
||||||
|
if email, ok := candidate.Traits["email"].(string); ok && strings.EqualFold(email, loginID) {
|
||||||
|
return candidate.ID, nil
|
||||||
|
}
|
||||||
|
if phone, ok := candidate.Traits["phone_number"].(string); ok && strings.EqualFold(phone, loginID) {
|
||||||
|
return candidate.ID, nil
|
||||||
|
}
|
||||||
|
if lids, ok := candidate.Traits["custom_login_ids"].([]any); ok {
|
||||||
|
for _, lid := range lids {
|
||||||
|
if s, ok := lid.(string); ok && strings.EqualFold(s, loginID) {
|
||||||
|
return candidate.ID, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if lids, ok := candidate.Traits["custom_login_ids"].([]string); ok {
|
||||||
|
for _, lid := range lids {
|
||||||
|
if strings.EqualFold(lid, loginID) {
|
||||||
|
return candidate.ID, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (o *OryProvider) getIdentity(identityID string) (*KratosIdentity, error) {
|
func (o *OryProvider) getIdentity(identityID string) (*KratosIdentity, error) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"baron-sso-backend/internal/domain"
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
@@ -50,19 +51,24 @@ func TestUpdateUserPassword_Success(t *testing.T) {
|
|||||||
if got := q.Get("credentials_identifier"); got != loginID {
|
if got := q.Get("credentials_identifier"); got != loginID {
|
||||||
t.Fatalf("expected credentials_identifier=%s, got=%s", loginID, got)
|
t.Fatalf("expected credentials_identifier=%s, got=%s", loginID, got)
|
||||||
}
|
}
|
||||||
_ = json.NewEncoder(w).Encode([]map[string]string{
|
_ = json.NewEncoder(w).Encode([]map[string]any{
|
||||||
{"id": identityID},
|
{
|
||||||
|
"id": identityID,
|
||||||
|
"traits": map[string]any{
|
||||||
|
"email": loginID,
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if r.URL.Path != "/admin/identities/"+identityID {
|
if r.URL.Path != "/admin/identities/"+identityID {
|
||||||
t.Fatalf("unexpected identity lookup path: %s", r.URL.Path)
|
t.Fatalf("unexpected identity lookup path: %s", r.URL.Path)
|
||||||
}
|
}
|
||||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
"id": identityID,
|
"id": identityID,
|
||||||
"schema_id": "default",
|
"schema_id": "default",
|
||||||
"state": "active",
|
"state": "active",
|
||||||
"traits": map[string]interface{}{
|
"traits": map[string]any{
|
||||||
"email": loginID,
|
"email": loginID,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -120,17 +126,22 @@ func TestUpdateUserPassword_ServerError(t *testing.T) {
|
|||||||
switch {
|
switch {
|
||||||
case strings.HasPrefix(r.URL.Path, "/admin/identities") && r.Method == http.MethodGet:
|
case strings.HasPrefix(r.URL.Path, "/admin/identities") && r.Method == http.MethodGet:
|
||||||
if r.URL.Path == "/admin/identities" {
|
if r.URL.Path == "/admin/identities" {
|
||||||
_ = json.NewEncoder(w).Encode([]map[string]string{
|
_ = json.NewEncoder(w).Encode([]map[string]any{
|
||||||
{"id": "abc"},
|
{
|
||||||
|
"id": "abc",
|
||||||
|
"traits": map[string]any{
|
||||||
|
"email": "user@example.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if r.URL.Path == "/admin/identities/abc" {
|
if r.URL.Path == "/admin/identities/abc" {
|
||||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
"id": "abc",
|
"id": "abc",
|
||||||
"schema_id": "default",
|
"schema_id": "default",
|
||||||
"state": "active",
|
"state": "active",
|
||||||
"traits": map[string]interface{}{
|
"traits": map[string]any{
|
||||||
"email": "user@example.com",
|
"email": "user@example.com",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -163,8 +174,13 @@ func TestFindIdentityID_QueryEncoding(t *testing.T) {
|
|||||||
if values.Get("credentials_identifier") != loginID {
|
if values.Get("credentials_identifier") != loginID {
|
||||||
t.Fatalf("expected credentials_identifier=%s, got=%s", loginID, values.Get("credentials_identifier"))
|
t.Fatalf("expected credentials_identifier=%s, got=%s", loginID, values.Get("credentials_identifier"))
|
||||||
}
|
}
|
||||||
_ = json.NewEncoder(w).Encode([]map[string]string{
|
_ = json.NewEncoder(w).Encode([]map[string]any{
|
||||||
{"id": "id-123"},
|
{
|
||||||
|
"id": "id-123",
|
||||||
|
"traits": map[string]any{
|
||||||
|
"email": loginID,
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -181,3 +197,67 @@ func TestFindIdentityID_QueryEncoding(t *testing.T) {
|
|||||||
t.Fatalf("expected id-123, got %s", id)
|
t.Fatalf("expected id-123, got %s", id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestOryProvider_CreateUser_CustomIDSupport(t *testing.T) {
|
||||||
|
const (
|
||||||
|
email = "newuser@test.com"
|
||||||
|
name = "New User"
|
||||||
|
customUuid = "550e8400-e29b-41d4-a716-446655440000"
|
||||||
|
password = "secret123456"
|
||||||
|
kratosId = "kratos-gen-id"
|
||||||
|
)
|
||||||
|
|
||||||
|
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case r.URL.Path == "/admin/identities" && r.Method == http.MethodGet:
|
||||||
|
// No existing identity
|
||||||
|
_ = json.NewEncoder(w).Encode([]any{})
|
||||||
|
return
|
||||||
|
case r.URL.Path == "/admin/identities/"+customUuid && r.Method == http.MethodGet:
|
||||||
|
// No identity with this ID
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
case r.URL.Path == "/admin/identities" && r.Method == http.MethodPost:
|
||||||
|
var body map[string]any
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||||
|
|
||||||
|
// Verify ID is NOT in the root to avoid "unknown field id" error
|
||||||
|
if _, exists := body["id"]; exists {
|
||||||
|
t.Fatalf("payload MUST NOT contain root 'id' field for compatibility")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify external_id and metadata_admin
|
||||||
|
if got := body["external_id"]; got != customUuid {
|
||||||
|
t.Fatalf("expected external_id %s, got %v", customUuid, got)
|
||||||
|
}
|
||||||
|
meta, ok := body["metadata_admin"].(map[string]any)
|
||||||
|
if !ok || meta["original_uuid"] != customUuid {
|
||||||
|
t.Fatalf("expected metadata_admin.original_uuid %s, got %v", customUuid, meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"id": kratosId,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
provider := &OryProvider{
|
||||||
|
KratosAdminURL: "http://kratos-admin.local",
|
||||||
|
HTTPClient: clientForHandler(handler),
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := provider.CreateUser(&domain.BrokerUser{
|
||||||
|
ID: customUuid,
|
||||||
|
Email: email,
|
||||||
|
Name: name,
|
||||||
|
}, password)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateUser failed: %v", err)
|
||||||
|
}
|
||||||
|
if id != kratosId {
|
||||||
|
t.Fatalf("expected Kratos generated ID %s, got %s", kratosId, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ func (s *relyingPartyService) enqueueDefaultRelyingPartyRelations(ctx context.Co
|
|||||||
func (s *relyingPartyService) Create(ctx context.Context, tenantID string, client domain.HydraClient) (*domain.RelyingParty, error) {
|
func (s *relyingPartyService) Create(ctx context.Context, tenantID string, client domain.HydraClient) (*domain.RelyingParty, error) {
|
||||||
// 1. Create Client in Hydra
|
// 1. Create Client in Hydra
|
||||||
if client.Metadata == nil {
|
if client.Metadata == nil {
|
||||||
client.Metadata = make(map[string]interface{})
|
client.Metadata = make(map[string]any)
|
||||||
}
|
}
|
||||||
client.Metadata["tenant_id"] = tenantID
|
client.Metadata["tenant_id"] = tenantID
|
||||||
|
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ func TestRelyingPartyService_Create_Success(t *testing.T) {
|
|||||||
tenantID := "tenant-1"
|
tenantID := "tenant-1"
|
||||||
inputClient := domain.HydraClient{
|
inputClient := domain.HydraClient{
|
||||||
ClientName: "Test App",
|
ClientName: "Test App",
|
||||||
Metadata: map[string]interface{}{
|
Metadata: map[string]any{
|
||||||
"user_id": "creator-1",
|
"user_id": "creator-1",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -127,7 +127,7 @@ func TestRelyingPartyService_Get_Success(t *testing.T) {
|
|||||||
_ = json.NewEncoder(w).Encode(domain.HydraClient{
|
_ = json.NewEncoder(w).Encode(domain.HydraClient{
|
||||||
ClientID: clientID,
|
ClientID: clientID,
|
||||||
ClientName: "Hydra Name",
|
ClientName: "Hydra Name",
|
||||||
Metadata: map[string]interface{}{
|
Metadata: map[string]any{
|
||||||
"tenant_id": "tenant-1",
|
"tenant_id": "tenant-1",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -180,7 +180,7 @@ func TestRelyingPartyService_Delete_Success(t *testing.T) {
|
|||||||
if r.Method == http.MethodGet && strings.Contains(r.URL.Path, clientID) {
|
if r.Method == http.MethodGet && strings.Contains(r.URL.Path, clientID) {
|
||||||
_ = json.NewEncoder(w).Encode(domain.HydraClient{
|
_ = json.NewEncoder(w).Encode(domain.HydraClient{
|
||||||
ClientID: clientID,
|
ClientID: clientID,
|
||||||
Metadata: map[string]interface{}{
|
Metadata: map[string]any{
|
||||||
"tenant_id": tenantID,
|
"tenant_id": tenantID,
|
||||||
"user_id": "creator-1",
|
"user_id": "creator-1",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/ioutil"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
@@ -82,7 +82,7 @@ func (s *SmsServiceImpl) SendSms(to, content string) error {
|
|||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
respBody, err := ioutil.ReadAll(resp.Body)
|
respBody, err := io.ReadAll(resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("error reading response body: %w", err)
|
return fmt.Errorf("error reading response body: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -336,7 +336,7 @@ func (s *tenantService) ProvisionTenantByDomain(ctx context.Context, domainName
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, g := range groups {
|
for _, g := range groups {
|
||||||
rawConfig, ok := g.Config["autoProvisioning"].(map[string]interface{})
|
rawConfig, ok := g.Config["autoProvisioning"].(map[string]any)
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -346,12 +346,12 @@ func (s *tenantService) ProvisionTenantByDomain(ctx context.Context, domainName
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
mapping, ok := rawConfig["mappingRules"].(map[string]interface{})
|
mapping, ok := rawConfig["mappingRules"].(map[string]any)
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
rule, ok := mapping[domainName].(map[string]interface{})
|
rule, ok := mapping[domainName].(map[string]any)
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/mock"
|
"github.com/stretchr/testify/mock"
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// --- Local Mocks to avoid collisions ---
|
// --- Local Mocks to avoid collisions ---
|
||||||
@@ -185,6 +186,10 @@ func (m *MockUserRepoForTenant) FindTenantIDByLoginID(ctx context.Context, login
|
|||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *MockUserRepoForTenant) DB() *gorm.DB {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// --- Tests ---
|
// --- Tests ---
|
||||||
|
|
||||||
func TestTenantService_RegisterTenant_AutoVerify(t *testing.T) {
|
func TestTenantService_RegisterTenant_AutoVerify(t *testing.T) {
|
||||||
|
|||||||
@@ -225,7 +225,7 @@ func (s *userGroupService) AddMember(ctx context.Context, groupID, userID string
|
|||||||
if err == nil && identity != nil {
|
if err == nil && identity != nil {
|
||||||
traits := identity.Traits
|
traits := identity.Traits
|
||||||
if traits == nil {
|
if traits == nil {
|
||||||
traits = make(map[string]interface{})
|
traits = make(map[string]any)
|
||||||
}
|
}
|
||||||
delete(traits, "companyCode")
|
delete(traits, "companyCode")
|
||||||
delete(traits, "companyCodes")
|
delete(traits, "companyCodes")
|
||||||
@@ -349,7 +349,7 @@ func mapUserGroupKratosIdentityToLocalUser(identity KratosIdentity) *domain.User
|
|||||||
return user
|
return user
|
||||||
}
|
}
|
||||||
|
|
||||||
func userGroupTraitString(traits map[string]interface{}, key string) string {
|
func userGroupTraitString(traits map[string]any, key string) string {
|
||||||
if traits == nil {
|
if traits == nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
@@ -363,14 +363,14 @@ func userGroupTraitString(traits map[string]interface{}, key string) string {
|
|||||||
return fmt.Sprint(value)
|
return fmt.Sprint(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
func userGroupTraitStringArray(traits map[string]interface{}, key string) []string {
|
func userGroupTraitStringArray(traits map[string]any, key string) []string {
|
||||||
if traits == nil {
|
if traits == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
switch value := traits[key].(type) {
|
switch value := traits[key].(type) {
|
||||||
case []string:
|
case []string:
|
||||||
return value
|
return value
|
||||||
case []interface{}:
|
case []any:
|
||||||
items := make([]string, 0, len(value))
|
items := make([]string, 0, len(value))
|
||||||
for _, item := range value {
|
for _, item := range value {
|
||||||
if str, ok := item.(string); ok && str != "" {
|
if str, ok := item.(string); ok && str != "" {
|
||||||
|
|||||||
@@ -135,6 +135,10 @@ func (m *MockUserRepository) FindTenantIDByLoginID(ctx context.Context, loginID
|
|||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *MockUserRepository) DB() *gorm.DB {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
type MockKetoOutboxRepository struct {
|
type MockKetoOutboxRepository struct {
|
||||||
mock.Mock
|
mock.Mock
|
||||||
}
|
}
|
||||||
@@ -250,7 +254,7 @@ func TestUserGroupService_AddMember(t *testing.T) {
|
|||||||
// Mock Kratos
|
// Mock Kratos
|
||||||
mockKratos.On("GetIdentity", mock.Anything, userID).Return(&KratosIdentity{
|
mockKratos.On("GetIdentity", mock.Anything, userID).Return(&KratosIdentity{
|
||||||
ID: userID,
|
ID: userID,
|
||||||
Traits: map[string]interface{}{"email": "user@test.com"},
|
Traits: map[string]any{"email": "user@test.com"},
|
||||||
State: "active",
|
State: "active",
|
||||||
}, nil)
|
}, nil)
|
||||||
mockKratos.On("UpdateIdentity", mock.Anything, userID, mock.Anything, "active").Return(&KratosIdentity{}, nil)
|
mockKratos.On("UpdateIdentity", mock.Anything, userID, mock.Anything, "active").Return(&KratosIdentity{}, nil)
|
||||||
@@ -295,18 +299,18 @@ func TestUserGroupService_AddMemberUpsertsLocalReadModelWhenMissing(t *testing.T
|
|||||||
mockTenantRepo.On("FindByID", mock.Anything, tenantID).Return(&domain.Tenant{ID: tenantID, Slug: tenantSlug}, nil)
|
mockTenantRepo.On("FindByID", mock.Anything, tenantID).Return(&domain.Tenant{ID: tenantID, Slug: tenantSlug}, nil)
|
||||||
mockKratos.On("GetIdentity", mock.Anything, userID).Return(&KratosIdentity{
|
mockKratos.On("GetIdentity", mock.Anything, userID).Return(&KratosIdentity{
|
||||||
ID: userID,
|
ID: userID,
|
||||||
Traits: map[string]interface{}{
|
Traits: map[string]any{
|
||||||
"email": "user@test.com",
|
"email": "user@test.com",
|
||||||
"name": "User Test",
|
"name": "User Test",
|
||||||
},
|
},
|
||||||
State: "active",
|
State: "active",
|
||||||
}, nil)
|
}, nil)
|
||||||
mockKratos.On("UpdateIdentity", mock.Anything, userID, mock.MatchedBy(func(traits map[string]interface{}) bool {
|
mockKratos.On("UpdateIdentity", mock.Anything, userID, mock.MatchedBy(func(traits map[string]any) bool {
|
||||||
_, hasCompanyCode := traits["companyCode"]
|
_, hasCompanyCode := traits["companyCode"]
|
||||||
return !hasCompanyCode && traits["tenant_id"] == tenantID && traits["department"] == "Sales"
|
return !hasCompanyCode && traits["tenant_id"] == tenantID && traits["department"] == "Sales"
|
||||||
}), "active").Return(&KratosIdentity{
|
}), "active").Return(&KratosIdentity{
|
||||||
ID: userID,
|
ID: userID,
|
||||||
Traits: map[string]interface{}{
|
Traits: map[string]any{
|
||||||
"email": "user@test.com",
|
"email": "user@test.com",
|
||||||
"name": "User Test",
|
"name": "User Test",
|
||||||
"tenant_id": tenantID,
|
"tenant_id": tenantID,
|
||||||
@@ -402,7 +406,7 @@ func TestUserGroupService_Get_WithKratosFallback(t *testing.T) {
|
|||||||
|
|
||||||
mockKratos.On("GetIdentity", mock.Anything, "u1").Return(&KratosIdentity{
|
mockKratos.On("GetIdentity", mock.Anything, "u1").Return(&KratosIdentity{
|
||||||
ID: "u1",
|
ID: "u1",
|
||||||
Traits: map[string]interface{}{"name": "User One", "email": "user1@example.com"},
|
Traits: map[string]any{"name": "User One", "email": "user1@example.com"},
|
||||||
}, nil)
|
}, nil)
|
||||||
|
|
||||||
group, err := svc.Get(context.Background(), groupID)
|
group, err := svc.Get(context.Background(), groupID)
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ func MapKratosIdentityToLocalUser(identity KratosIdentity) domain.User {
|
|||||||
return user
|
return user
|
||||||
}
|
}
|
||||||
|
|
||||||
func kratosProjectionTraitString(traits map[string]interface{}, key string) string {
|
func kratosProjectionTraitString(traits map[string]any, key string) string {
|
||||||
if traits == nil {
|
if traits == nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
@@ -124,14 +124,14 @@ func kratosProjectionTraitString(traits map[string]interface{}, key string) stri
|
|||||||
return fmt.Sprint(value)
|
return fmt.Sprint(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
func kratosProjectionTraitStringArray(traits map[string]interface{}, key string) []string {
|
func kratosProjectionTraitStringArray(traits map[string]any, key string) []string {
|
||||||
if traits == nil {
|
if traits == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
switch value := traits[key].(type) {
|
switch value := traits[key].(type) {
|
||||||
case []string:
|
case []string:
|
||||||
return value
|
return value
|
||||||
case []interface{}:
|
case []any:
|
||||||
items := make([]string, 0, len(value))
|
items := make([]string, 0, len(value))
|
||||||
for _, item := range value {
|
for _, item := range value {
|
||||||
if str, ok := item.(string); ok && strings.TrimSpace(str) != "" {
|
if str, ok := item.(string); ok && strings.TrimSpace(str) != "" {
|
||||||
|
|||||||
@@ -48,12 +48,12 @@ func TestUserProjectionSyncService_ReconcileReplacesProjectionFromKratos(t *test
|
|||||||
kratos.On("ListIdentities", ctx).Return([]KratosIdentity{
|
kratos.On("ListIdentities", ctx).Return([]KratosIdentity{
|
||||||
{
|
{
|
||||||
ID: "00000000-0000-0000-0000-000000000101",
|
ID: "00000000-0000-0000-0000-000000000101",
|
||||||
Traits: map[string]interface{}{
|
Traits: map[string]any{
|
||||||
"email": "one@example.com",
|
"email": "one@example.com",
|
||||||
"name": "One",
|
"name": "One",
|
||||||
"phone_number": "+821012345678",
|
"phone_number": "+821012345678",
|
||||||
"companyCode": "saman",
|
"companyCode": "saman",
|
||||||
"companyCodes": []interface{}{"saman", "group-a"},
|
"companyCodes": []any{"saman", "group-a"},
|
||||||
"tenant_id": tenantID,
|
"tenant_id": tenantID,
|
||||||
"department": "DX",
|
"department": "DX",
|
||||||
"customAttr": "kept",
|
"customAttr": "kept",
|
||||||
@@ -101,7 +101,7 @@ func TestMapKratosIdentityToLocalUserPreservesArchivedStatus(t *testing.T) {
|
|||||||
user := MapKratosIdentityToLocalUser(KratosIdentity{
|
user := MapKratosIdentityToLocalUser(KratosIdentity{
|
||||||
ID: "00000000-0000-0000-0000-000000000201",
|
ID: "00000000-0000-0000-0000-000000000201",
|
||||||
State: domain.UserStatusArchived,
|
State: domain.UserStatusArchived,
|
||||||
Traits: map[string]interface{}{
|
Traits: map[string]any{
|
||||||
"email": "archived@example.com",
|
"email": "archived@example.com",
|
||||||
"name": "Archived User",
|
"name": "Archived User",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -737,7 +737,7 @@ type WorksmobileUserPatchPayload struct {
|
|||||||
DomainID int64 `json:"domainId"`
|
DomainID int64 `json:"domainId"`
|
||||||
Email string `json:"email,omitempty"`
|
Email string `json:"email,omitempty"`
|
||||||
UserExternalKey string `json:"userExternalKey,omitempty"`
|
UserExternalKey string `json:"userExternalKey,omitempty"`
|
||||||
UserName WorksmobileUserName `json:"userName,omitempty"`
|
UserName WorksmobileUserName `json:"userName"`
|
||||||
CellPhone string `json:"cellPhone,omitempty"`
|
CellPhone string `json:"cellPhone,omitempty"`
|
||||||
EmployeeNumber string `json:"employeeNumber,omitempty"`
|
EmployeeNumber string `json:"employeeNumber,omitempty"`
|
||||||
AliasEmails []string `json:"aliasEmails,omitempty"`
|
AliasEmails []string `json:"aliasEmails,omitempty"`
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestWorksmobileSyncServiceRejectsAliasLocalPartAlreadyUsedByOtherUser(t *testing.T) {
|
func TestWorksmobileSyncServiceRejectsAliasLocalPartAlreadyUsedByOtherUser(t *testing.T) {
|
||||||
@@ -1260,3 +1261,7 @@ func (f *fakeWorksmobileUserRepo) IsLoginIDTaken(ctx context.Context, loginID st
|
|||||||
func (f *fakeWorksmobileUserRepo) FindTenantIDByLoginID(ctx context.Context, loginID string) (string, error) {
|
func (f *fakeWorksmobileUserRepo) FindTenantIDByLoginID(ctx context.Context, loginID string) (string, error) {
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *fakeWorksmobileUserRepo) DB() *gorm.DB {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ func MaskSensitiveJSON(data []byte) []byte {
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
var obj interface{}
|
var obj any
|
||||||
if err := json.Unmarshal(data, &obj); err != nil {
|
if err := json.Unmarshal(data, &obj); err != nil {
|
||||||
// Not a JSON object/array, return as is
|
// Not a JSON object/array, return as is
|
||||||
return data
|
return data
|
||||||
@@ -48,10 +48,10 @@ func MaskSensitiveJSON(data []byte) []byte {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func maskValue(v interface{}) interface{} {
|
func maskValue(v any) any {
|
||||||
switch val := v.(type) {
|
switch val := v.(type) {
|
||||||
case map[string]interface{}:
|
case map[string]any:
|
||||||
newMap := make(map[string]interface{}, len(val))
|
newMap := make(map[string]any, len(val))
|
||||||
for k, v := range val {
|
for k, v := range val {
|
||||||
if isSensitive(k) {
|
if isSensitive(k) {
|
||||||
newMap[k] = "*****"
|
newMap[k] = "*****"
|
||||||
@@ -60,8 +60,8 @@ func maskValue(v interface{}) interface{} {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return newMap
|
return newMap
|
||||||
case []interface{}:
|
case []any:
|
||||||
newArr := make([]interface{}, len(val))
|
newArr := make([]any, len(val))
|
||||||
for i, v := range val {
|
for i, v := range val {
|
||||||
newArr[i] = maskValue(v)
|
newArr[i] = maskValue(v)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"baron-sso-backend/internal/domain"
|
"baron-sso-backend/internal/domain"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -111,13 +113,7 @@ func GeneratePasswordWithPolicy(policy *domain.PasswordPolicy) (string, error) {
|
|||||||
if additionalTypes > 0 {
|
if additionalTypes > 0 {
|
||||||
pool := make([]string, 0, len(selected))
|
pool := make([]string, 0, len(selected))
|
||||||
for _, cat := range selected {
|
for _, cat := range selected {
|
||||||
isRequired := false
|
isRequired := slices.Contains(required, cat)
|
||||||
for _, req := range required {
|
|
||||||
if req == cat {
|
|
||||||
isRequired = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !isRequired {
|
if !isRequired {
|
||||||
pool = append(pool, cat)
|
pool = append(pool, cat)
|
||||||
}
|
}
|
||||||
@@ -150,12 +146,12 @@ func GeneratePasswordWithPolicy(policy *domain.PasswordPolicy) (string, error) {
|
|||||||
passwordRunes = append(passwordRunes, ch)
|
passwordRunes = append(passwordRunes, ch)
|
||||||
}
|
}
|
||||||
|
|
||||||
combined := ""
|
var combined strings.Builder
|
||||||
for _, charset := range selected {
|
for _, charset := range selected {
|
||||||
combined += charset
|
combined.WriteString(charset)
|
||||||
}
|
}
|
||||||
for len(passwordRunes) < minLength {
|
for len(passwordRunes) < minLength {
|
||||||
ch, err := randomChar(combined)
|
ch, err := randomChar(combined.String())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// ValidateIDPCompatibility checks if the provided IDP supports all required fields defined in the BrokerUser model.
|
// ValidateIDPCompatibility checks if the provided IDP supports all required fields defined in the BrokerUser model.
|
||||||
func ValidateIDPCompatibility(brokerModel interface{}, idp domain.IdentityProvider) error {
|
func ValidateIDPCompatibility(brokerModel any, idp domain.IdentityProvider) error {
|
||||||
metadata, err := idp.GetMetadata()
|
metadata, err := idp.GetMetadata()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to fetch metadata from IDP %s: %w", idp.Name(), err)
|
return fmt.Errorf("failed to fetch metadata from IDP %s: %w", idp.Name(), err)
|
||||||
@@ -20,12 +20,12 @@ func ValidateIDPCompatibility(brokerModel interface{}, idp domain.IdentityProvid
|
|||||||
}
|
}
|
||||||
|
|
||||||
t := reflect.TypeOf(brokerModel)
|
t := reflect.TypeOf(brokerModel)
|
||||||
if t.Kind() == reflect.Ptr {
|
if t.Kind() == reflect.Pointer {
|
||||||
t = t.Elem()
|
t = t.Elem()
|
||||||
}
|
}
|
||||||
|
|
||||||
for i := 0; i < t.NumField(); i++ {
|
for field := range t.Fields() {
|
||||||
field := t.Field(i)
|
field := field
|
||||||
|
|
||||||
// Check "required" tag
|
// Check "required" tag
|
||||||
isRequired := field.Tag.Get("required") == "true"
|
isRequired := field.Tag.Get("required") == "true"
|
||||||
@@ -47,8 +47,8 @@ func ValidateIDPCompatibility(brokerModel interface{}, idp domain.IdentityProvid
|
|||||||
if fieldName == "attributes" {
|
if fieldName == "attributes" {
|
||||||
reqKeys := field.Tag.Get("required_keys")
|
reqKeys := field.Tag.Get("required_keys")
|
||||||
if reqKeys != "" {
|
if reqKeys != "" {
|
||||||
keys := strings.Split(reqKeys, ",")
|
keys := strings.SplitSeq(reqKeys, ",")
|
||||||
for _, key := range keys {
|
for key := range keys {
|
||||||
key = strings.TrimSpace(key)
|
key = strings.TrimSpace(key)
|
||||||
if !supportedMap[key] {
|
if !supportedMap[key] {
|
||||||
return fmt.Errorf("IDP %s does not support required custom attribute: %s", idp.Name(), key)
|
return fmt.Errorf("IDP %s does not support required custom attribute: %s", idp.Name(), key)
|
||||||
|
|||||||
@@ -188,6 +188,9 @@ active = "Active"
|
|||||||
blocked = "Blocked"
|
blocked = "Blocked"
|
||||||
failure = "Failure"
|
failure = "Failure"
|
||||||
inactive = "Inactive"
|
inactive = "Inactive"
|
||||||
|
new = "New"
|
||||||
ok = "Ok"
|
ok = "Ok"
|
||||||
pending = "Pending"
|
pending = "Pending"
|
||||||
success = "Success"
|
success = "Success"
|
||||||
|
unchanged = "Unchanged"
|
||||||
|
updated = "Updated"
|
||||||
|
|||||||
@@ -188,6 +188,9 @@ active = "활성"
|
|||||||
blocked = "차단됨"
|
blocked = "차단됨"
|
||||||
failure = "실패"
|
failure = "실패"
|
||||||
inactive = "비활성"
|
inactive = "비활성"
|
||||||
|
new = "신규"
|
||||||
ok = "정상"
|
ok = "정상"
|
||||||
pending = "준비 중"
|
pending = "준비 중"
|
||||||
success = "성공"
|
success = "성공"
|
||||||
|
unchanged = "동일"
|
||||||
|
updated = "수정"
|
||||||
|
|||||||
@@ -188,6 +188,9 @@ active = ""
|
|||||||
blocked = ""
|
blocked = ""
|
||||||
failure = ""
|
failure = ""
|
||||||
inactive = ""
|
inactive = ""
|
||||||
|
new = ""
|
||||||
ok = ""
|
ok = ""
|
||||||
pending = ""
|
pending = ""
|
||||||
success = ""
|
success = ""
|
||||||
|
unchanged = ""
|
||||||
|
updated = ""
|
||||||
|
|||||||
78
common/pnpm-lock.yaml
generated
78
common/pnpm-lock.yaml
generated
@@ -63,8 +63,8 @@ importers:
|
|||||||
specifier: ^3.3.0
|
specifier: ^3.3.0
|
||||||
version: 3.3.1(oidc-client-ts@3.5.0)(react@19.2.6)
|
version: 3.3.1(oidc-client-ts@3.5.0)(react@19.2.6)
|
||||||
react-router-dom:
|
react-router-dom:
|
||||||
specifier: ^6.28.2
|
specifier: ^7.15.1
|
||||||
version: 6.30.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
version: 7.16.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||||
tailwind-merge:
|
tailwind-merge:
|
||||||
specifier: ^3.4.0
|
specifier: ^3.4.0
|
||||||
version: 3.6.0
|
version: 3.6.0
|
||||||
@@ -469,7 +469,7 @@ importers:
|
|||||||
specifier: ^8.0.14
|
specifier: ^8.0.14
|
||||||
version: 8.0.14(@types/node@25.7.0)(jiti@1.21.7)
|
version: 8.0.14(@types/node@25.7.0)(jiti@1.21.7)
|
||||||
vitest:
|
vitest:
|
||||||
specifier: ^4.1.6
|
specifier: 4.1.6
|
||||||
version: 4.1.6(@types/node@25.7.0)(@vitest/coverage-v8@4.1.6)(jsdom@28.1.0)(vite@8.0.14(@types/node@25.7.0)(jiti@1.21.7))
|
version: 4.1.6(@types/node@25.7.0)(@vitest/coverage-v8@4.1.6)(jsdom@28.1.0)(vite@8.0.14(@types/node@25.7.0)(jiti@1.21.7))
|
||||||
|
|
||||||
packages:
|
packages:
|
||||||
@@ -549,28 +549,24 @@ packages:
|
|||||||
engines: {node: '>=14.21.3'}
|
engines: {node: '>=14.21.3'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
'@biomejs/cli-linux-arm64@2.4.16':
|
'@biomejs/cli-linux-arm64@2.4.16':
|
||||||
resolution: {integrity: sha512-2kFb4//jxfZaP6D+Rj5VkHkxgyD9EoRAVBEQb8PKRv+s4NO2zYNJKXFaJmK1CmhufJOWEfpHKaRbOja7qjmdhQ==}
|
resolution: {integrity: sha512-2kFb4//jxfZaP6D+Rj5VkHkxgyD9EoRAVBEQb8PKRv+s4NO2zYNJKXFaJmK1CmhufJOWEfpHKaRbOja7qjmdhQ==}
|
||||||
engines: {node: '>=14.21.3'}
|
engines: {node: '>=14.21.3'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@biomejs/cli-linux-x64-musl@2.4.16':
|
'@biomejs/cli-linux-x64-musl@2.4.16':
|
||||||
resolution: {integrity: sha512-iHDS+MCM65DPqWGu+ECC3uoALyj2H7F4nVUPxIPjz/PIl94EUu+EDfGZDzFP+NY1EOPVt9NQvwFqq7HdMmowdg==}
|
resolution: {integrity: sha512-iHDS+MCM65DPqWGu+ECC3uoALyj2H7F4nVUPxIPjz/PIl94EUu+EDfGZDzFP+NY1EOPVt9NQvwFqq7HdMmowdg==}
|
||||||
engines: {node: '>=14.21.3'}
|
engines: {node: '>=14.21.3'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
'@biomejs/cli-linux-x64@2.4.16':
|
'@biomejs/cli-linux-x64@2.4.16':
|
||||||
resolution: {integrity: sha512-NbcBbi/nJqn5baae6wqRXdS7Gadf2uRpehSh6vMSYpG8OhkXl/Xg8aorWrJ+9VWqAT5ml90alLvorkpMW0nBwQ==}
|
resolution: {integrity: sha512-NbcBbi/nJqn5baae6wqRXdS7Gadf2uRpehSh6vMSYpG8OhkXl/Xg8aorWrJ+9VWqAT5ml90alLvorkpMW0nBwQ==}
|
||||||
engines: {node: '>=14.21.3'}
|
engines: {node: '>=14.21.3'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@biomejs/cli-win32-arm64@2.4.16':
|
'@biomejs/cli-win32-arm64@2.4.16':
|
||||||
resolution: {integrity: sha512-0rgImMsNb5v/chhkIFe3wu7PEFClS6RBAYUijGL9UsYN3PanSaoK24HSSuSJb1pYbYYVjzAyZTl3gtjJ84BM8A==}
|
resolution: {integrity: sha512-0rgImMsNb5v/chhkIFe3wu7PEFClS6RBAYUijGL9UsYN3PanSaoK24HSSuSJb1pYbYYVjzAyZTl3gtjJ84BM8A==}
|
||||||
@@ -1095,10 +1091,6 @@ packages:
|
|||||||
'@radix-ui/rect@1.1.1':
|
'@radix-ui/rect@1.1.1':
|
||||||
resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
|
resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
|
||||||
|
|
||||||
'@remix-run/router@1.23.2':
|
|
||||||
resolution: {integrity: sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==}
|
|
||||||
engines: {node: '>=14.0.0'}
|
|
||||||
|
|
||||||
'@rolldown/binding-android-arm64@1.0.0':
|
'@rolldown/binding-android-arm64@1.0.0':
|
||||||
resolution: {integrity: sha512-TWMZnRLMe63C2Lhyicviu7ZHaU4kxa6PS3rofvc9GmcvptzNN11BcfQ4Sl7MwTOsisQoa2keB/EBdNCAnUo8vA==}
|
resolution: {integrity: sha512-TWMZnRLMe63C2Lhyicviu7ZHaU4kxa6PS3rofvc9GmcvptzNN11BcfQ4Sl7MwTOsisQoa2keB/EBdNCAnUo8vA==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
@@ -1164,84 +1156,72 @@ packages:
|
|||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@rolldown/binding-linux-arm64-gnu@1.0.2':
|
'@rolldown/binding-linux-arm64-gnu@1.0.2':
|
||||||
resolution: {integrity: sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==}
|
resolution: {integrity: sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@rolldown/binding-linux-arm64-musl@1.0.0':
|
'@rolldown/binding-linux-arm64-musl@1.0.0':
|
||||||
resolution: {integrity: sha512-EIVjy2cgd7uuMMo94FVkBp7F6DhcZAUwNURkSG3RwUmvAXR6s0ISxM81U+IydcZByPG0pZIHsf1b6kTxoFDgJA==}
|
resolution: {integrity: sha512-EIVjy2cgd7uuMMo94FVkBp7F6DhcZAUwNURkSG3RwUmvAXR6s0ISxM81U+IydcZByPG0pZIHsf1b6kTxoFDgJA==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
'@rolldown/binding-linux-arm64-musl@1.0.2':
|
'@rolldown/binding-linux-arm64-musl@1.0.2':
|
||||||
resolution: {integrity: sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==}
|
resolution: {integrity: sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
'@rolldown/binding-linux-ppc64-gnu@1.0.0':
|
'@rolldown/binding-linux-ppc64-gnu@1.0.0':
|
||||||
resolution: {integrity: sha512-JEwwOPcwTLAcpDQlqSmjEmfs63xJnSiUNIGvLcDLUHCWK4XowpS/7c7tUsUH6uT/ct6bMUTdXKfI8967FYj6mg==}
|
resolution: {integrity: sha512-JEwwOPcwTLAcpDQlqSmjEmfs63xJnSiUNIGvLcDLUHCWK4XowpS/7c7tUsUH6uT/ct6bMUTdXKfI8967FYj6mg==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
cpu: [ppc64]
|
cpu: [ppc64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@rolldown/binding-linux-ppc64-gnu@1.0.2':
|
'@rolldown/binding-linux-ppc64-gnu@1.0.2':
|
||||||
resolution: {integrity: sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==}
|
resolution: {integrity: sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
cpu: [ppc64]
|
cpu: [ppc64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@rolldown/binding-linux-s390x-gnu@1.0.0':
|
'@rolldown/binding-linux-s390x-gnu@1.0.0':
|
||||||
resolution: {integrity: sha512-0wjCFhLrihtAubnT9iA0N++0pSV0z5Hg7tNGdNJ4RFaINceHadoF+kiFGyY1qSSNVIAZtLotG8Ju1bgDPkjnFA==}
|
resolution: {integrity: sha512-0wjCFhLrihtAubnT9iA0N++0pSV0z5Hg7tNGdNJ4RFaINceHadoF+kiFGyY1qSSNVIAZtLotG8Ju1bgDPkjnFA==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
cpu: [s390x]
|
cpu: [s390x]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@rolldown/binding-linux-s390x-gnu@1.0.2':
|
'@rolldown/binding-linux-s390x-gnu@1.0.2':
|
||||||
resolution: {integrity: sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==}
|
resolution: {integrity: sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
cpu: [s390x]
|
cpu: [s390x]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@rolldown/binding-linux-x64-gnu@1.0.0':
|
'@rolldown/binding-linux-x64-gnu@1.0.0':
|
||||||
resolution: {integrity: sha512-Dfn7iak9BcMMePxcoJfpSbWqnEyrp/dRF63/8qW/eHBdOZov6x5aShLLEYGYdIeSJ6vMLK/XCVB+lGIxm41bQA==}
|
resolution: {integrity: sha512-Dfn7iak9BcMMePxcoJfpSbWqnEyrp/dRF63/8qW/eHBdOZov6x5aShLLEYGYdIeSJ6vMLK/XCVB+lGIxm41bQA==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@rolldown/binding-linux-x64-gnu@1.0.2':
|
'@rolldown/binding-linux-x64-gnu@1.0.2':
|
||||||
resolution: {integrity: sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==}
|
resolution: {integrity: sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@rolldown/binding-linux-x64-musl@1.0.0':
|
'@rolldown/binding-linux-x64-musl@1.0.0':
|
||||||
resolution: {integrity: sha512-5/utzzDmD/pD/bmuaUcbTf/sZYy0aztwIVlfpoW1fTjCZ0BaPOMVWGZL1zvgxyi7ZIVYWlxKONHmSbHuiOh8Jw==}
|
resolution: {integrity: sha512-5/utzzDmD/pD/bmuaUcbTf/sZYy0aztwIVlfpoW1fTjCZ0BaPOMVWGZL1zvgxyi7ZIVYWlxKONHmSbHuiOh8Jw==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
'@rolldown/binding-linux-x64-musl@1.0.2':
|
'@rolldown/binding-linux-x64-musl@1.0.2':
|
||||||
resolution: {integrity: sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==}
|
resolution: {integrity: sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
'@rolldown/binding-openharmony-arm64@1.0.0':
|
'@rolldown/binding-openharmony-arm64@1.0.0':
|
||||||
resolution: {integrity: sha512-ouJs8VcUomfLfpbUECqFMRqdV4x6aeAK3MA4m6vTrJJjKyWTV5KnxZx7Jd9G+GlDaQQxubcba00x16OyJ1meig==}
|
resolution: {integrity: sha512-ouJs8VcUomfLfpbUECqFMRqdV4x6aeAK3MA4m6vTrJJjKyWTV5KnxZx7Jd9G+GlDaQQxubcba00x16OyJ1meig==}
|
||||||
@@ -1940,28 +1920,24 @@ packages:
|
|||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
lightningcss-linux-arm64-musl@1.32.0:
|
lightningcss-linux-arm64-musl@1.32.0:
|
||||||
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
|
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
|
||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
lightningcss-linux-x64-gnu@1.32.0:
|
lightningcss-linux-x64-gnu@1.32.0:
|
||||||
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
|
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
|
||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
lightningcss-linux-x64-musl@1.32.0:
|
lightningcss-linux-x64-musl@1.32.0:
|
||||||
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
|
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
|
||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
lightningcss-win32-arm64-msvc@1.32.0:
|
lightningcss-win32-arm64-msvc@1.32.0:
|
||||||
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
|
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
|
||||||
@@ -2219,13 +2195,6 @@ packages:
|
|||||||
'@types/react':
|
'@types/react':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
react-router-dom@6.30.3:
|
|
||||||
resolution: {integrity: sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==}
|
|
||||||
engines: {node: '>=14.0.0'}
|
|
||||||
peerDependencies:
|
|
||||||
react: '>=16.8'
|
|
||||||
react-dom: '>=16.8'
|
|
||||||
|
|
||||||
react-router-dom@7.15.0:
|
react-router-dom@7.15.0:
|
||||||
resolution: {integrity: sha512-VcrVg64Fo8nwBvDscajG8gRTLIuTC6N50nb22l2HOOV4PTOHgoGp8mUjy9wLiHYoYTSYI36tUnXZgasSRFZorQ==}
|
resolution: {integrity: sha512-VcrVg64Fo8nwBvDscajG8gRTLIuTC6N50nb22l2HOOV4PTOHgoGp8mUjy9wLiHYoYTSYI36tUnXZgasSRFZorQ==}
|
||||||
engines: {node: '>=20.0.0'}
|
engines: {node: '>=20.0.0'}
|
||||||
@@ -2233,11 +2202,12 @@ packages:
|
|||||||
react: '>=18'
|
react: '>=18'
|
||||||
react-dom: '>=18'
|
react-dom: '>=18'
|
||||||
|
|
||||||
react-router@6.30.3:
|
react-router-dom@7.16.0:
|
||||||
resolution: {integrity: sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==}
|
resolution: {integrity: sha512-kMUAbimWB5FVbF4Bce4bJsiKJWLIUHq/mEG8+CFDnCSgltptBiG5nguducmsJeGKytlCvQud9Qhzpn49iduTlA==}
|
||||||
engines: {node: '>=14.0.0'}
|
engines: {node: '>=20.0.0'}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: '>=16.8'
|
react: '>=18'
|
||||||
|
react-dom: '>=18'
|
||||||
|
|
||||||
react-router@7.15.0:
|
react-router@7.15.0:
|
||||||
resolution: {integrity: sha512-HW9vYwuM8f4yx66Izy8xfrzCM+SBJluoZcCbww9A1TySax11S5Vgw6fi3ZjMONw9J4gQwngL7PzkyIpJJpJ7RQ==}
|
resolution: {integrity: sha512-HW9vYwuM8f4yx66Izy8xfrzCM+SBJluoZcCbww9A1TySax11S5Vgw6fi3ZjMONw9J4gQwngL7PzkyIpJJpJ7RQ==}
|
||||||
@@ -2249,6 +2219,16 @@ packages:
|
|||||||
react-dom:
|
react-dom:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
react-router@7.16.0:
|
||||||
|
resolution: {integrity: sha512-wArC8lVyJb3+jM9OpDyW6hLCizACWkvQR/sSGqSs+o5uEXEtGlqdZ4v8hENR3Jad6i+LRkK93q/+bQAcvl6V1A==}
|
||||||
|
engines: {node: '>=20.0.0'}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=18'
|
||||||
|
react-dom: '>=18'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
react-dom:
|
||||||
|
optional: true
|
||||||
|
|
||||||
react-style-singleton@2.2.3:
|
react-style-singleton@2.2.3:
|
||||||
resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==}
|
resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -3210,8 +3190,6 @@ snapshots:
|
|||||||
|
|
||||||
'@radix-ui/rect@1.1.1': {}
|
'@radix-ui/rect@1.1.1': {}
|
||||||
|
|
||||||
'@remix-run/router@1.23.2': {}
|
|
||||||
|
|
||||||
'@rolldown/binding-android-arm64@1.0.0':
|
'@rolldown/binding-android-arm64@1.0.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -4199,23 +4177,17 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.2.14
|
'@types/react': 19.2.14
|
||||||
|
|
||||||
react-router-dom@6.30.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
|
|
||||||
dependencies:
|
|
||||||
'@remix-run/router': 1.23.2
|
|
||||||
react: 19.2.6
|
|
||||||
react-dom: 19.2.6(react@19.2.6)
|
|
||||||
react-router: 6.30.3(react@19.2.6)
|
|
||||||
|
|
||||||
react-router-dom@7.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
|
react-router-dom@7.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 19.2.6
|
react: 19.2.6
|
||||||
react-dom: 19.2.6(react@19.2.6)
|
react-dom: 19.2.6(react@19.2.6)
|
||||||
react-router: 7.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
react-router: 7.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||||
|
|
||||||
react-router@6.30.3(react@19.2.6):
|
react-router-dom@7.16.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@remix-run/router': 1.23.2
|
|
||||||
react: 19.2.6
|
react: 19.2.6
|
||||||
|
react-dom: 19.2.6(react@19.2.6)
|
||||||
|
react-router: 7.16.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||||
|
|
||||||
react-router@7.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
|
react-router@7.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -4225,6 +4197,14 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
react-dom: 19.2.6(react@19.2.6)
|
react-dom: 19.2.6(react@19.2.6)
|
||||||
|
|
||||||
|
react-router@7.16.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
|
||||||
|
dependencies:
|
||||||
|
cookie: 1.1.1
|
||||||
|
react: 19.2.6
|
||||||
|
set-cookie-parser: 2.7.2
|
||||||
|
optionalDependencies:
|
||||||
|
react-dom: 19.2.6(react@19.2.6)
|
||||||
|
|
||||||
react-style-singleton@2.2.3(@types/react@19.2.14)(react@19.2.6):
|
react-style-singleton@2.2.3(@types/react@19.2.14)(react@19.2.6):
|
||||||
dependencies:
|
dependencies:
|
||||||
get-nonce: 1.0.1
|
get-nonce: 1.0.1
|
||||||
|
|||||||
@@ -1739,6 +1739,17 @@ additional = "Additional Affiliated/Manageable Tenants"
|
|||||||
primary = "Representative Affiliated Tenant"
|
primary = "Representative Affiliated Tenant"
|
||||||
title = "Affiliation & Organization Info"
|
title = "Affiliation & Organization Info"
|
||||||
|
|
||||||
|
[ui.admin.users.field]
|
||||||
|
department = "Department"
|
||||||
|
grade = "Grade"
|
||||||
|
jobtitle = "Job Title"
|
||||||
|
name = "Name"
|
||||||
|
phone = "Phone"
|
||||||
|
position = "Position"
|
||||||
|
role = "Role"
|
||||||
|
status = "Status"
|
||||||
|
tenant = "Tenant"
|
||||||
|
|
||||||
[ui.admin.users.list]
|
[ui.admin.users.list]
|
||||||
add = "User Add"
|
add = "User Add"
|
||||||
bulk_import = "Bulk Import"
|
bulk_import = "Bulk Import"
|
||||||
@@ -2878,6 +2889,8 @@ title = "Login request status by company and app"
|
|||||||
export = "Export"
|
export = "Export"
|
||||||
|
|
||||||
[ui.admin.users.bulk]
|
[ui.admin.users.bulk]
|
||||||
|
modified_fields = "Modified Fields:"
|
||||||
|
no_changes = "No changes"
|
||||||
permission_placeholder = "Select permission"
|
permission_placeholder = "Select permission"
|
||||||
status_placeholder = "Select status"
|
status_placeholder = "Select status"
|
||||||
|
|
||||||
|
|||||||
@@ -2203,6 +2203,17 @@ additional = "추가 소속/관리 테넌트"
|
|||||||
primary = "대표 소속 테넌트"
|
primary = "대표 소속 테넌트"
|
||||||
title = "소속 및 조직 정보"
|
title = "소속 및 조직 정보"
|
||||||
|
|
||||||
|
[ui.admin.users.field]
|
||||||
|
department = "부서"
|
||||||
|
grade = "직급"
|
||||||
|
jobtitle = "직무"
|
||||||
|
name = "이름"
|
||||||
|
phone = "전화번호"
|
||||||
|
position = "직책"
|
||||||
|
role = "역할"
|
||||||
|
status = "상태"
|
||||||
|
tenant = "테넌트"
|
||||||
|
|
||||||
[ui.admin.users.list]
|
[ui.admin.users.list]
|
||||||
add = "사용자 추가"
|
add = "사용자 추가"
|
||||||
bulk_import = "일괄 임포트"
|
bulk_import = "일괄 임포트"
|
||||||
@@ -3300,6 +3311,8 @@ title = "회사별 앱별 로그인 요청 현황"
|
|||||||
export = "내보내기"
|
export = "내보내기"
|
||||||
|
|
||||||
[ui.admin.users.bulk]
|
[ui.admin.users.bulk]
|
||||||
|
modified_fields = "수정 항목:"
|
||||||
|
no_changes = "변경 사항 없음"
|
||||||
permission_placeholder = "권한 선택"
|
permission_placeholder = "권한 선택"
|
||||||
status_placeholder = "상태 선택"
|
status_placeholder = "상태 선택"
|
||||||
|
|
||||||
|
|||||||
@@ -1782,8 +1782,10 @@ acknowledge_warning = ""
|
|||||||
create_missing_tenant = ""
|
create_missing_tenant = ""
|
||||||
do_move = ""
|
do_move = ""
|
||||||
download_template = ""
|
download_template = ""
|
||||||
|
modified_fields = ""
|
||||||
move_group = ""
|
move_group = ""
|
||||||
move_title = ""
|
move_title = ""
|
||||||
|
no_changes = ""
|
||||||
no_department = ""
|
no_department = ""
|
||||||
schema_warning = ""
|
schema_warning = ""
|
||||||
select_group = ""
|
select_group = ""
|
||||||
@@ -2082,6 +2084,17 @@ additional = ""
|
|||||||
primary = ""
|
primary = ""
|
||||||
title = ""
|
title = ""
|
||||||
|
|
||||||
|
[ui.admin.users.field]
|
||||||
|
department = ""
|
||||||
|
grade = ""
|
||||||
|
jobtitle = ""
|
||||||
|
name = ""
|
||||||
|
phone = ""
|
||||||
|
position = ""
|
||||||
|
role = ""
|
||||||
|
status = ""
|
||||||
|
tenant = ""
|
||||||
|
|
||||||
[ui.admin.users.list]
|
[ui.admin.users.list]
|
||||||
add = ""
|
add = ""
|
||||||
bulk_import = ""
|
bulk_import = ""
|
||||||
|
|||||||
@@ -1,93 +0,0 @@
|
|||||||
# 멀티 IDP 아키텍처 및 마이그레이션 전략
|
|
||||||
|
|
||||||
본 문서는 Primary IDP 변경(예: Descope → Hydra/Authentik) 시 시스템의 유연성을 보장하기 위한 아키텍처 설계 전략을 기술합니다.
|
|
||||||
|
|
||||||
## 핵심 전략
|
|
||||||
|
|
||||||
1. **Broker 패턴 고도화**: 백엔드가 모든 인증 요청을 중계하며, 클라이언트는 특정 IDP에 종속되지 않습니다.
|
|
||||||
2. **추상화 계층 (Abstraction Layer)**: 비즈니스 로직은 `IdentityProvider` 인터페이스에만 의존하며, 구체적인 구현체(Descope, Hydra 등)는 알 필요가 없습니다.
|
|
||||||
3. **식별자 분리 (Identifier Decoupling)**: 외부 IDP의 `sub` 값과 내부 시스템의 `user_id`를 분리하여 매핑합니다.
|
|
||||||
|
|
||||||
## 상세 설계
|
|
||||||
|
|
||||||
### 1. IdentityProvider 인터페이스 확장
|
|
||||||
단순 메타데이터 조회를 넘어, 인증의 핵심 라이프사이클을 모두 포함하도록 인터페이스를 확장해야 합니다.
|
|
||||||
|
|
||||||
```go
|
|
||||||
type IdentityProvider interface {
|
|
||||||
// 메타데이터 및 스키마 검증
|
|
||||||
Name() string
|
|
||||||
GetMetadata() (*IDPMetadata, error)
|
|
||||||
|
|
||||||
// 핵심 인증/인가 기능
|
|
||||||
SignUp(ctx context.Context, user *BrokerUser, password string) (*AuthResult, error)
|
|
||||||
Login(ctx context.Context, loginID, password string) (*AuthResult, error)
|
|
||||||
VerifyToken(ctx context.Context, token string) (*TokenInfo, error)
|
|
||||||
|
|
||||||
// 사용자 관리
|
|
||||||
GetUser(ctx context.Context, id string) (*BrokerUser, error)
|
|
||||||
UpdateUser(ctx context.Context, user *BrokerUser) error
|
|
||||||
DeleteUser(ctx context.Context, id string) error
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Provider Factory 패턴
|
|
||||||
설정(`config.yaml` 또는 환경변수)에 따라 사용할 IDP 구현체를 런타임에 결정합니다.
|
|
||||||
|
|
||||||
```go
|
|
||||||
func NewIdentityProvider(config Config) (domain.IdentityProvider, error) {
|
|
||||||
switch config.IDPType {
|
|
||||||
case "descope":
|
|
||||||
return service.NewDescopeProvider(config.Descope)
|
|
||||||
case "hydra":
|
|
||||||
return service.NewHydraProvider(config.Hydra)
|
|
||||||
case "authentik":
|
|
||||||
return service.NewAuthentikProvider(config.Authentik)
|
|
||||||
default:
|
|
||||||
return nil, fmt.Errorf("unsupported IDP type: %s", config.IDPType)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. 식별자 매핑 (ID Mapping)
|
|
||||||
IDP 변경 시 가장 큰 문제는 사용자 고유 ID(`sub`)가 바뀌는 것입니다. 이를 해결하기 위해 Baron SSO 내부 전용 UUID를 사용하고 매핑 테이블을 둡니다.
|
|
||||||
|
|
||||||
* **User Table (Baron Internal)**: `id` (UUID), `email`, ...
|
|
||||||
* **IDP Link Table**: `baron_user_id`, `idp_provider` (e.g., descope), `idp_subject_id` (e.g., U12345)
|
|
||||||
|
|
||||||
## 구현 예시 (Descope)
|
|
||||||
|
|
||||||
현재 단계에서 즉시 사용 가능한 `DescopeProvider`의 구현 예시입니다.
|
|
||||||
|
|
||||||
```go
|
|
||||||
// DescopeProvider는 IdentityProvider 인터페이스를 구현합니다.
|
|
||||||
type DescopeProvider struct {
|
|
||||||
Client *client.DescopeClient
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *DescopeProvider) SignUp(ctx context.Context, user *domain.BrokerUser, password string) (*domain.AuthResult, error) {
|
|
||||||
// BrokerUser를 Descope User 객체로 변환
|
|
||||||
descopeUser := &descope.User{
|
|
||||||
Name: user.Name,
|
|
||||||
Email: user.Email,
|
|
||||||
Phone: user.PhoneNumber,
|
|
||||||
CustomAttributes: user.Attributes,
|
|
||||||
}
|
|
||||||
|
|
||||||
// SDK 호출
|
|
||||||
authInfo, err := d.Client.Auth.Password().SignUp(ctx, user.Email, descopeUser, password)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err\n }
|
|
||||||
|
|
||||||
// 결과 반환
|
|
||||||
return &domain.AuthResult{
|
|
||||||
UserID: authInfo.User.UserID,
|
|
||||||
Token: authInfo.SessionToken.JWT,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 향후 지원 후보 (Candidates)
|
|
||||||
|
|
||||||
* **Ory Hydra**: 자체적인 OAuth2/OIDC 서버 구축이 필요할 때 적합. 높은 커스터마이징 가능.
|
|
||||||
* **Authentik**: 오픈소스 IDP로, 설치형(Self-hosted) 환경에서 강력한 기능을 제공.
|
|
||||||
Reference in New Issue
Block a user