feat(backend): JIT-provision the local User on first Authentik login

AuthentikStrategy no longer rejects a valid token whose user has no local
row — it creates the User from the token claims (given_name/family_name/
email) via the new shared resolveOrProvisionAuthentikUser helper, which is
race-safe (P2002 -> re-read) and captures the User to the sync log. The WS
token path (TokenVerificationService.verifyAuthentik) and OnboardingService
now use the same helper, removing three copies of the lookup/create logic.

A provisioned user still has no Membership and therefore no rights: LT role
assignment from Authentik groups is the remaining gap; Verantwortliche go
through the onboarding approval flow.

Tests: provision-user.spec.ts (existing/new/race/rethrow); npm test green
at 51. Docs updated.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-10 08:05:18 +02:00
co-authored by Claude Sonnet 5
parent 6ed5aa2c76
commit f03b209e84
6 changed files with 201 additions and 56 deletions
+104
View File
@@ -0,0 +1,104 @@
import { Prisma } from '@prisma/client';
import { resolveOrProvisionAuthentikUser } from './provision-user';
const CLAIMS = {
sub: 'sub-1',
email: 'New.Person@Example.org',
firstName: 'New',
lastName: 'Person',
};
function p2002() {
return new Prisma.PrismaClientKnownRequestError('unique', {
code: 'P2002',
clientVersion: 'test',
});
}
function makeMocks() {
const sync = { capture: jest.fn().mockResolvedValue(undefined) };
return { sync };
}
describe('resolveOrProvisionAuthentikUser', () => {
it('returns the existing user without creating or capturing', async () => {
const { sync } = makeMocks();
const existing = { id: 'u-1', authentikSub: 'sub-1', memberships: [] };
const prisma = {
user: {
findUnique: jest.fn().mockResolvedValue(existing),
create: jest.fn(),
},
};
const res = await resolveOrProvisionAuthentikUser(prisma as never, sync as never, CLAIMS);
expect(res).toBe(existing);
expect(prisma.user.create).not.toHaveBeenCalled();
expect(sync.capture).not.toHaveBeenCalled();
});
it('provisions a new user from claims (lowercased email) and captures it', async () => {
const { sync } = makeMocks();
const prisma = {
user: {
findUnique: jest.fn().mockResolvedValue(null),
create: jest.fn(({ data }: { data: Record<string, unknown> }) =>
Promise.resolve({ id: 'u-2', ...data }),
),
},
};
const res = await resolveOrProvisionAuthentikUser(prisma as never, sync as never, CLAIMS);
expect(prisma.user.create).toHaveBeenCalledWith({
data: {
authentikSub: 'sub-1',
email: 'new.person@example.org',
firstName: 'New',
lastName: 'Person',
},
});
expect(res.memberships).toEqual([]);
expect(sync.capture).toHaveBeenCalledWith('User', 'CREATE', 'u-2', expect.anything());
});
it('recovers from a concurrent-create race (P2002) by re-reading', async () => {
const { sync } = makeMocks();
const raced = { id: 'u-3', authentikSub: 'sub-1', memberships: [] };
const prisma = {
user: {
findUnique: jest
.fn()
.mockResolvedValueOnce(null) // first check: not there yet
.mockResolvedValueOnce(raced), // after the failed insert: it exists
create: jest.fn().mockRejectedValue(p2002()),
},
};
const res = await resolveOrProvisionAuthentikUser(prisma as never, sync as never, CLAIMS);
expect(res).toBe(raced);
expect(sync.capture).not.toHaveBeenCalled();
});
it('rethrows a P2002 when the row still cannot be found', async () => {
const { sync } = makeMocks();
const prisma = {
user: {
findUnique: jest.fn().mockResolvedValue(null),
create: jest.fn().mockRejectedValue(p2002()),
},
};
await expect(
resolveOrProvisionAuthentikUser(prisma as never, sync as never, CLAIMS),
).rejects.toBeInstanceOf(Prisma.PrismaClientKnownRequestError);
});
it('rethrows a non-P2002 error', async () => {
const { sync } = makeMocks();
const prisma = {
user: {
findUnique: jest.fn().mockResolvedValue(null),
create: jest.fn().mockRejectedValue(new Error('db down')),
},
};
await expect(
resolveOrProvisionAuthentikUser(prisma as never, sync as never, CLAIMS),
).rejects.toThrow('db down');
});
});