mirror of
https://github.com/graphql-hive/console
synced 2026-04-21 06:37:15 +00:00
fix: inherit OIDC provider email (#7810)
This commit is contained in:
parent
e3afb155b2
commit
7aac422acc
15 changed files with 695 additions and 69 deletions
5
.changeset/honest-knives-sleep.md
Normal file
5
.changeset/honest-knives-sleep.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
'hive': patch
|
||||
---
|
||||
|
||||
Propagate updated email address from OIDC provider. This fixes a bug where a user was locked out of the Hive account after the email of the user on the OIDC provider side changed.
|
||||
7
.github/workflows/tests-integration.yaml
vendored
7
.github/workflows/tests-integration.yaml
vendored
|
|
@ -54,6 +54,13 @@ jobs:
|
|||
uses: mikefarah/yq@4839dbbf80445070a31c7a9c1055da527db2d5ee # v4.44.6
|
||||
with:
|
||||
cmd: yq -i 'del(.services.*.volumes)' docker/docker-compose.community.yml
|
||||
# tests need to access host machine for OIDC stuff
|
||||
- name: make host machine accessible to server container
|
||||
uses: mikefarah/yq@4839dbbf80445070a31c7a9c1055da527db2d5ee # v4.44.6
|
||||
with:
|
||||
cmd:
|
||||
yq -i '.services.server.extra_hosts[] |= sub("host-gateway"; "172.17.0.1")'
|
||||
./integration-tests/docker-compose.integration.yaml
|
||||
|
||||
- name: get cpu count for vitest
|
||||
id: cpu-cores
|
||||
|
|
|
|||
|
|
@ -61,40 +61,8 @@ export default defineConfig({
|
|||
};
|
||||
},
|
||||
async getEmailConfirmationLink(input: string | { email: string; now: number }) {
|
||||
const email = typeof input === 'string' ? input : input.email;
|
||||
const now = new Date(
|
||||
typeof input === 'string' ? Date.now() - 10_000 : input.now,
|
||||
).toISOString();
|
||||
const url = new URL('http://localhost:3014/_history');
|
||||
url.searchParams.set('after', now);
|
||||
|
||||
return await asyncRetry(
|
||||
async () => {
|
||||
const emails = await fetch(url.toString())
|
||||
.then(res => res.json())
|
||||
.then(emails =>
|
||||
emails.filter(e => e.to === email && e.subject === 'Verify your email'),
|
||||
);
|
||||
|
||||
if (emails.length === 0) {
|
||||
throw new Error('Could not find email');
|
||||
}
|
||||
|
||||
// take the latest one
|
||||
const result = emails[emails.length - 1];
|
||||
|
||||
const urlMatch = result.body.match(/href=\"(http:\/\/[^\s"]+)/);
|
||||
if (!urlMatch) throw new Error('No URL found in email');
|
||||
|
||||
const confirmUrl = new URL(urlMatch[1]);
|
||||
return confirmUrl.pathname + confirmUrl.search;
|
||||
},
|
||||
{
|
||||
retries: 10,
|
||||
minTimeout: 1000,
|
||||
maxTimeout: 10000,
|
||||
},
|
||||
);
|
||||
const url = await seed.pollForEmailVerificationLink(input);
|
||||
return url.pathname + url.search;
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -164,10 +164,12 @@ services:
|
|||
# Auth
|
||||
WEB_APP_URL: '${HIVE_APP_BASE_URL}'
|
||||
AUTH_ORGANIZATION_OIDC: '1'
|
||||
AUTH_REQUIRE_EMAIL_VERIFICATION: '0'
|
||||
AUTH_REQUIRE_EMAIL_VERIFICATION: '1'
|
||||
SUPERTOKENS_CONNECTION_URI: http://supertokens:3567
|
||||
SUPERTOKENS_API_KEY: '${SUPERTOKENS_API_KEY}'
|
||||
GRAPHQL_PUBLIC_ORIGIN: http://localhost:8082
|
||||
extra_hosts:
|
||||
- 'host.docker.internal:host-gateway'
|
||||
|
||||
broker:
|
||||
image: redpandadata/redpanda:latest
|
||||
|
|
|
|||
|
|
@ -21,12 +21,14 @@
|
|||
"@hive/commerce": "workspace:*",
|
||||
"@hive/schema": "workspace:*",
|
||||
"@hive/server": "workspace:*",
|
||||
"@hive/service-common": "workspace:*",
|
||||
"@hive/storage": "workspace:*",
|
||||
"@theguild/federation-composition": "0.21.3",
|
||||
"@trpc/client": "10.45.3",
|
||||
"@trpc/server": "10.45.3",
|
||||
"@types/async-retry": "1.4.8",
|
||||
"@types/dockerode": "3.3.43",
|
||||
"@types/set-cookie-parser": "2.4.10",
|
||||
"async-retry": "1.3.3",
|
||||
"bcryptjs": "2.4.3",
|
||||
"csv-parse": "5.6.0",
|
||||
|
|
@ -37,6 +39,7 @@
|
|||
"graphql-sse": "2.6.0",
|
||||
"human-id": "4.1.1",
|
||||
"ioredis": "5.8.2",
|
||||
"set-cookie-parser": "2.7.1",
|
||||
"slonik": "30.4.4",
|
||||
"strip-ansi": "7.1.2",
|
||||
"tslib": "2.8.1",
|
||||
|
|
|
|||
368
integration-tests/testkit/oidc-integration.ts
Normal file
368
integration-tests/testkit/oidc-integration.ts
Normal file
|
|
@ -0,0 +1,368 @@
|
|||
import type { AddressInfo } from 'node:net';
|
||||
import humanId from 'human-id';
|
||||
import setCookie from 'set-cookie-parser';
|
||||
import { sql, type DatabasePool } from 'slonik';
|
||||
import z from 'zod';
|
||||
import formDataPlugin from '@fastify/formbody';
|
||||
import { createServer, type FastifyReply, type FastifyRequest } from '@hive/service-common';
|
||||
import { graphql } from './gql';
|
||||
import { execute } from './graphql';
|
||||
import { getServiceHost, pollForEmailVerificationLink } from './utils';
|
||||
|
||||
const apiAddress = await getServiceHost('server', 8082);
|
||||
|
||||
async function createMockOIDCServer() {
|
||||
const host =
|
||||
process.env.RUN_AGAINST_LOCAL_SERVICES === '1' ? 'localhost' : 'host.docker.internal';
|
||||
const server = await createServer({
|
||||
sentryErrorHandler: false,
|
||||
log: {
|
||||
requests: false,
|
||||
level: 'silent',
|
||||
},
|
||||
name: '',
|
||||
});
|
||||
await server.register(formDataPlugin);
|
||||
|
||||
let registeredHandler: typeof handler;
|
||||
|
||||
async function handler(request: FastifyRequest, reply: FastifyReply): Promise<void> {
|
||||
if (!handler) {
|
||||
throw new Error('No handler registered');
|
||||
}
|
||||
return await registeredHandler(request, reply);
|
||||
}
|
||||
|
||||
server.route({
|
||||
method: 'POST',
|
||||
url: '/token',
|
||||
handler,
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: 'GET',
|
||||
url: '/userinfo',
|
||||
handler,
|
||||
});
|
||||
|
||||
await server.listen({
|
||||
port: 0,
|
||||
host: '0.0.0.0',
|
||||
});
|
||||
|
||||
return {
|
||||
url: 'http://' + host + ':' + (server.server.address() as AddressInfo).port,
|
||||
setHandler(newHandler: typeof handler) {
|
||||
registeredHandler = newHandler;
|
||||
},
|
||||
[Symbol.asyncDispose]: () => {
|
||||
server.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const CreateOIDCIntegrationMutation = graphql(`
|
||||
mutation TestKit_OIDCIntegration_CreateOIDCIntegrationMutation(
|
||||
$input: CreateOIDCIntegrationInput!
|
||||
) {
|
||||
createOIDCIntegration(input: $input) {
|
||||
ok {
|
||||
createdOIDCIntegration {
|
||||
id
|
||||
clientId
|
||||
clientSecretPreview
|
||||
tokenEndpoint
|
||||
userinfoEndpoint
|
||||
authorizationEndpoint
|
||||
additionalScopes
|
||||
oidcUserJoinOnly
|
||||
oidcUserAccessOnly
|
||||
}
|
||||
}
|
||||
error {
|
||||
message
|
||||
details {
|
||||
clientId
|
||||
clientSecret
|
||||
tokenEndpoint
|
||||
userinfoEndpoint
|
||||
authorizationEndpoint
|
||||
additionalScopes
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const UpdateOIDCIntegrationMutation = graphql(`
|
||||
mutation TestKit_OIDCIntegration_UpdateOIDCIntegrationMutation(
|
||||
$input: UpdateOIDCIntegrationInput!
|
||||
) {
|
||||
updateOIDCIntegration(input: $input) {
|
||||
ok {
|
||||
updatedOIDCIntegration {
|
||||
id
|
||||
tokenEndpoint
|
||||
userinfoEndpoint
|
||||
authorizationEndpoint
|
||||
clientId
|
||||
clientSecretPreview
|
||||
additionalScopes
|
||||
}
|
||||
}
|
||||
error {
|
||||
message
|
||||
details {
|
||||
clientId
|
||||
clientSecret
|
||||
tokenEndpoint
|
||||
userinfoEndpoint
|
||||
authorizationEndpoint
|
||||
additionalScopes
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const SendVerificationEmailMutation = graphql(`
|
||||
mutation TestKit_OIDCIntegration_SendVerificationEmailMutation(
|
||||
$input: SendVerificationEmailInput!
|
||||
) {
|
||||
sendVerificationEmail(input: $input) {
|
||||
ok {
|
||||
expiresAt
|
||||
}
|
||||
error {
|
||||
message
|
||||
emailAlreadyVerified
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const VerifyEmailMutation = graphql(`
|
||||
mutation TestKit_OIDCIntegration_VerifyEmailMutation($input: VerifyEmailInput!) {
|
||||
verifyEmail(input: $input) {
|
||||
ok {
|
||||
verified
|
||||
}
|
||||
error {
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
export async function createOIDCIntegration(args: {
|
||||
organizationId: string;
|
||||
accessToken: string;
|
||||
getPool: () => Promise<DatabasePool>;
|
||||
}) {
|
||||
const { accessToken: authToken, getPool } = args;
|
||||
const result = await execute({
|
||||
document: CreateOIDCIntegrationMutation,
|
||||
variables: {
|
||||
input: {
|
||||
organizationId: args.organizationId,
|
||||
additionalScopes: [],
|
||||
authorizationEndpoint: 'http://localhost:6666/noop/authoriation',
|
||||
tokenEndpoint: 'http://localhost:6666/noop/token',
|
||||
userinfoEndpoint: 'http://localhost:666/noop/userinfo',
|
||||
clientId: 'noop',
|
||||
clientSecret: 'noop',
|
||||
},
|
||||
},
|
||||
authToken,
|
||||
}).then(r => r.expectNoGraphQLErrors());
|
||||
|
||||
if (!result.createOIDCIntegration.ok) {
|
||||
throw new Error(result.createOIDCIntegration.error?.message ?? 'Unexpected error.');
|
||||
}
|
||||
|
||||
const oidcIntegration = result.createOIDCIntegration.ok.createdOIDCIntegration;
|
||||
|
||||
return {
|
||||
oidcIntegration,
|
||||
async registerFakeDomain() {
|
||||
const randomDomain =
|
||||
humanId({
|
||||
separator: '',
|
||||
capitalize: false,
|
||||
}) + '.local';
|
||||
|
||||
const pool = await getPool();
|
||||
const query = sql`
|
||||
INSERT INTO "oidc_integration_domains" (
|
||||
"organization_id"
|
||||
, "oidc_integration_id"
|
||||
, "domain_name"
|
||||
, "verified_at"
|
||||
) VALUES (
|
||||
${args.organizationId}
|
||||
, ${oidcIntegration.id}
|
||||
, ${randomDomain}
|
||||
, NOW()
|
||||
)
|
||||
`;
|
||||
|
||||
await pool.query(query);
|
||||
return randomDomain;
|
||||
},
|
||||
async createMockServerAndUpdateIntegrationEndpoints(args?: {
|
||||
additionalScopes?: Array<string>;
|
||||
clientId?: string;
|
||||
clientSecret?: string;
|
||||
}) {
|
||||
const server = await createMockOIDCServer();
|
||||
|
||||
const result = await execute({
|
||||
document: UpdateOIDCIntegrationMutation,
|
||||
variables: {
|
||||
input: {
|
||||
oidcIntegrationId: oidcIntegration.id,
|
||||
authorizationEndpoint: server.url + '/authorize',
|
||||
tokenEndpoint: server.url + '/token',
|
||||
userinfoEndpoint: server.url + '/userinfo',
|
||||
additionalScopes: args?.additionalScopes,
|
||||
clientId: args?.clientId,
|
||||
clientSecret: args?.clientSecret,
|
||||
},
|
||||
},
|
||||
authToken,
|
||||
}).then(r => r.expectNoGraphQLErrors());
|
||||
|
||||
if (!result.updateOIDCIntegration.ok) {
|
||||
throw new Error(result.updateOIDCIntegration.error?.message ?? 'Unexpected error.');
|
||||
}
|
||||
|
||||
return {
|
||||
setHandler: server.setHandler,
|
||||
setUser(args: { email: string; sub: string }) {
|
||||
server.setHandler(async (req, res) => {
|
||||
if (req.routeOptions.url === '/token') {
|
||||
return res.status(200).send({
|
||||
access_token: 'yolo',
|
||||
});
|
||||
}
|
||||
|
||||
if (req.routeOptions.url === '/userinfo') {
|
||||
return res.status(200).send({
|
||||
sub: args.sub,
|
||||
email: args.email,
|
||||
});
|
||||
}
|
||||
|
||||
console.log('unhandled', req.routeOptions.url);
|
||||
return res.status(404).send();
|
||||
});
|
||||
},
|
||||
async runGetAuthorizationUrl() {
|
||||
const baseUrl = 'http://' + apiAddress;
|
||||
const url = new URL('http://' + apiAddress + '/auth-api/authorisationurl');
|
||||
url.searchParams.set('thirdPartyId', 'oidc');
|
||||
url.searchParams.set('redirectURIOnProviderDashboard', baseUrl + '/');
|
||||
url.searchParams.set('oidc_id', oidcIntegration.id);
|
||||
const result = await fetch(url).then(res => res.json());
|
||||
|
||||
const urlWithQueryParams = new URL(result.urlWithQueryParams);
|
||||
return {
|
||||
codeChallenge: urlWithQueryParams.searchParams.get('code_challenge') ?? '',
|
||||
state: urlWithQueryParams.searchParams.get('state') ?? '',
|
||||
};
|
||||
},
|
||||
async runSignInUp(args: { state: string; code?: string }) {
|
||||
const url = new URL('http://' + apiAddress + '/auth-api/signinup');
|
||||
url.searchParams.set('oidc_id', oidcIntegration.id);
|
||||
|
||||
const result = await fetch(url, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
thirdPartyId: 'oidc',
|
||||
redirectURIInfo: {
|
||||
redirectURIOnProviderDashboard: '/',
|
||||
redirectURIQueryParams: {
|
||||
state: args.state,
|
||||
code: args.code ?? 'noop',
|
||||
},
|
||||
},
|
||||
}),
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'st-auth-mode': 'cookie',
|
||||
},
|
||||
});
|
||||
|
||||
if (result.status !== 200) {
|
||||
throw new Error('Failed ' + result.status + (await result.text()));
|
||||
}
|
||||
|
||||
const rawBody = await result.json();
|
||||
|
||||
const body = z
|
||||
.object({
|
||||
user: z.object({
|
||||
id: z.string(),
|
||||
emails: z.array(z.string()),
|
||||
loginMethods: z.array(
|
||||
z.object({
|
||||
recipeUserId: z.string(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
})
|
||||
.parse(rawBody);
|
||||
const cookies = setCookie.parse(result.headers.getSetCookie());
|
||||
return {
|
||||
accessToken: cookies.find(c => c.name === 'sAccessToken')?.value ?? ('' as string),
|
||||
user: {
|
||||
id: body.user.id,
|
||||
email: body.user.emails[0],
|
||||
userIdentityId: body.user.loginMethods[0]?.recipeUserId,
|
||||
},
|
||||
};
|
||||
},
|
||||
async confirmEmail(args: { userIdentityId: string; email: string }) {
|
||||
const now = Date.now();
|
||||
const sendMail = await execute({
|
||||
document: SendVerificationEmailMutation,
|
||||
variables: {
|
||||
input: {
|
||||
userIdentityId: args.userIdentityId,
|
||||
resend: true,
|
||||
},
|
||||
},
|
||||
authToken,
|
||||
}).then(e => e.expectNoGraphQLErrors());
|
||||
|
||||
if (!sendMail.sendVerificationEmail.ok) {
|
||||
throw new Error(sendMail.sendVerificationEmail.error?.message ?? 'Unknown error.');
|
||||
}
|
||||
|
||||
const url = await pollForEmailVerificationLink({
|
||||
email: args.email,
|
||||
now,
|
||||
});
|
||||
|
||||
const token = url.searchParams.get('token') ?? '';
|
||||
|
||||
const confirmMail = await execute({
|
||||
document: VerifyEmailMutation,
|
||||
variables: {
|
||||
input: {
|
||||
userIdentityId: args.userIdentityId,
|
||||
email: args.email,
|
||||
token,
|
||||
},
|
||||
},
|
||||
authToken,
|
||||
}).then(e => e.expectNoGraphQLErrors());
|
||||
|
||||
if (!confirmMail.verifyEmail.ok) {
|
||||
throw new Error(confirmMail.verifyEmail.error?.message ?? 'Unknown error.');
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -53,13 +53,9 @@ import {
|
|||
updateTargetValidationSettings,
|
||||
} from './flow';
|
||||
import * as GraphQLSchema from './gql/graphql';
|
||||
import {
|
||||
BreakingChangeFormulaType,
|
||||
ProjectType,
|
||||
SchemaPolicyInput,
|
||||
TargetAccessScope,
|
||||
} from './gql/graphql';
|
||||
import { ProjectType, SchemaPolicyInput, TargetAccessScope } from './gql/graphql';
|
||||
import { execute } from './graphql';
|
||||
import { createOIDCIntegration } from './oidc-integration.js';
|
||||
import {
|
||||
CreateSavedFilterMutation,
|
||||
DeleteSavedFilterMutation,
|
||||
|
|
@ -70,7 +66,7 @@ import {
|
|||
} from './saved-filters';
|
||||
import { UpdateSchemaPolicyForOrganization, UpdateSchemaPolicyForProject } from './schema-policy';
|
||||
import { collect, CollectedOperation, legacyCollect } from './usage';
|
||||
import { generateUnique, getServiceHost } from './utils';
|
||||
import { generateUnique, getServiceHost, pollForEmailVerificationLink } from './utils';
|
||||
|
||||
function createConnectionPool() {
|
||||
const pg = {
|
||||
|
|
@ -106,11 +102,28 @@ export function initSeed() {
|
|||
return sharedDBPoolPromise.then(res => res.pool);
|
||||
}
|
||||
|
||||
async function doAuthenticate(email: string, oidcIntegrationId?: string) {
|
||||
return await authenticate(await getPool(), email, oidcIntegrationId);
|
||||
async function doAuthenticate(
|
||||
email: string,
|
||||
opts?: {
|
||||
oidcIntegrationId?: string;
|
||||
verifyEmail?: boolean;
|
||||
},
|
||||
) {
|
||||
const auth = await authenticate(await getPool(), email, opts?.oidcIntegrationId);
|
||||
|
||||
if (opts?.verifyEmail ?? true) {
|
||||
const pool = await getPool();
|
||||
await pool.query(sql`
|
||||
INSERT INTO "email_verifications" ("user_identity_id", "email", "verified_at")
|
||||
VALUES (${auth.supertokensUserId}, ${email}, NOW())
|
||||
`);
|
||||
}
|
||||
|
||||
return auth;
|
||||
}
|
||||
|
||||
return {
|
||||
pollForEmailVerificationLink,
|
||||
async purgeOIDCDomains() {
|
||||
const pool = await getPool();
|
||||
await pool.query(sql`
|
||||
|
|
@ -162,15 +175,9 @@ export function initSeed() {
|
|||
},
|
||||
async createOwner(verifyEmail: boolean = true) {
|
||||
const ownerEmail = userEmail(generateUnique());
|
||||
const auth = await doAuthenticate(ownerEmail);
|
||||
|
||||
if (verifyEmail) {
|
||||
const pool = await getPool();
|
||||
await pool.query(sql`
|
||||
INSERT INTO "email_verifications" ("user_identity_id", "email", "verified_at")
|
||||
VALUES (${auth.supertokensUserId}, ${ownerEmail}, NOW())
|
||||
`);
|
||||
}
|
||||
const auth = await doAuthenticate(ownerEmail, {
|
||||
verifyEmail,
|
||||
});
|
||||
|
||||
const ownerRefreshToken = auth.refresh_token;
|
||||
const ownerToken = auth.access_token;
|
||||
|
|
@ -1159,9 +1166,10 @@ export function initSeed() {
|
|||
},
|
||||
);
|
||||
const memberEmail = userEmail(generateUnique());
|
||||
const memberToken = await doAuthenticate(memberEmail, oidcIntegrationId).then(
|
||||
r => r.access_token,
|
||||
);
|
||||
const memberToken = await doAuthenticate(memberEmail, {
|
||||
oidcIntegrationId,
|
||||
verifyEmail: true,
|
||||
}).then(r => r.access_token);
|
||||
|
||||
if (!oidcIntegrationId) {
|
||||
const invitationResult = await inviteToOrganization(
|
||||
|
|
@ -1375,6 +1383,13 @@ export function initSeed() {
|
|||
},
|
||||
};
|
||||
},
|
||||
createOIDCIntegration() {
|
||||
return createOIDCIntegration({
|
||||
organizationId: organization.id,
|
||||
accessToken: ownerToken,
|
||||
getPool: getPool,
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import asyncRetry from 'async-retry';
|
||||
import Docker from 'dockerode';
|
||||
import { humanId } from 'human-id';
|
||||
|
||||
|
|
@ -122,3 +123,37 @@ export function assertNonNullish<T>(
|
|||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
export async function pollForEmailVerificationLink(input: string | { email: string; now: number }) {
|
||||
const email = typeof input === 'string' ? input : input.email;
|
||||
const now = new Date(typeof input === 'string' ? Date.now() - 10_000 : input.now).toISOString();
|
||||
const url = new URL('http://localhost:3014/_history');
|
||||
url.searchParams.set('after', now);
|
||||
|
||||
return await asyncRetry(
|
||||
async () => {
|
||||
const emails = await fetch(url.toString())
|
||||
.then(res => res.json())
|
||||
.then(emails =>
|
||||
emails.filter((e: any) => e.to === email && e.subject === 'Verify your email'),
|
||||
);
|
||||
|
||||
if (emails.length === 0) {
|
||||
throw new Error('Could not find email');
|
||||
}
|
||||
|
||||
// take the latest one
|
||||
const result = emails[emails.length - 1];
|
||||
|
||||
const urlMatch = result.body.match(/href=\"(http:\/\/[^\s"]+)/);
|
||||
if (!urlMatch) throw new Error('No URL found in email');
|
||||
|
||||
return new URL(urlMatch[1]);
|
||||
},
|
||||
{
|
||||
retries: 10,
|
||||
minTimeout: 1000,
|
||||
maxTimeout: 10000,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
157
integration-tests/tests/api/auth/oidc.spec.ts
Normal file
157
integration-tests/tests/api/auth/oidc.spec.ts
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
import { graphql } from 'testkit/gql';
|
||||
import { execute } from 'testkit/graphql';
|
||||
import { initSeed } from 'testkit/seed';
|
||||
|
||||
const TestMeQuery = graphql(`
|
||||
query OIDC_TestMeQuery {
|
||||
me {
|
||||
id
|
||||
email
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
test.concurrent(
|
||||
'User can sign in/up with OIDC provider and confirm their email',
|
||||
async ({ expect }) => {
|
||||
const seed = initSeed();
|
||||
const email = seed.generateEmail();
|
||||
const { createOrg } = await seed.createOwner();
|
||||
const { createOIDCIntegration } = await createOrg();
|
||||
|
||||
const { createMockServerAndUpdateIntegrationEndpoints } = await createOIDCIntegration();
|
||||
const oidc = await createMockServerAndUpdateIntegrationEndpoints();
|
||||
|
||||
const auth = await oidc.runGetAuthorizationUrl();
|
||||
|
||||
oidc.setUser({
|
||||
sub: 'test-user',
|
||||
email,
|
||||
});
|
||||
|
||||
const result = await oidc.runSignInUp({
|
||||
state: auth.state,
|
||||
});
|
||||
|
||||
const [error] = await execute({
|
||||
document: TestMeQuery,
|
||||
authToken: result.accessToken,
|
||||
}).then(r => r.expectGraphQLErrors());
|
||||
|
||||
expect(error).toMatchObject({
|
||||
extensions: {
|
||||
code: 'VERIFY_EMAIL',
|
||||
},
|
||||
message: 'Your account is not verified. Please verify your email address.',
|
||||
});
|
||||
|
||||
await oidc.confirmEmail(result.user);
|
||||
const meResult = await execute({
|
||||
document: TestMeQuery,
|
||||
authToken: result.accessToken,
|
||||
}).then(r => r.expectNoGraphQLErrors());
|
||||
expect(meResult).toMatchObject({
|
||||
me: {
|
||||
email,
|
||||
id: expect.any(String),
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test.concurrent(
|
||||
'If the OIDC provider users email changes, the users email is updated upon login',
|
||||
async ({ expect }) => {
|
||||
const seed = initSeed();
|
||||
const oldEmail = seed.generateEmail();
|
||||
const newEmail = seed.generateEmail();
|
||||
const { createOrg } = await seed.createOwner();
|
||||
const { createOIDCIntegration } = await createOrg();
|
||||
|
||||
const { createMockServerAndUpdateIntegrationEndpoints } = await createOIDCIntegration();
|
||||
const oidc = await createMockServerAndUpdateIntegrationEndpoints();
|
||||
|
||||
let auth = await oidc.runGetAuthorizationUrl();
|
||||
|
||||
oidc.setUser({
|
||||
sub: 'test-user',
|
||||
email: oldEmail,
|
||||
});
|
||||
|
||||
let result = await oidc.runSignInUp({
|
||||
state: auth.state,
|
||||
});
|
||||
|
||||
await oidc.confirmEmail(result.user);
|
||||
|
||||
let meResult = await execute({
|
||||
document: TestMeQuery,
|
||||
authToken: result.accessToken,
|
||||
}).then(r => r.expectNoGraphQLErrors());
|
||||
expect(meResult).toMatchObject({
|
||||
me: {
|
||||
email: oldEmail,
|
||||
id: expect.any(String),
|
||||
},
|
||||
});
|
||||
|
||||
auth = await oidc.runGetAuthorizationUrl();
|
||||
|
||||
oidc.setUser({
|
||||
sub: 'test-user',
|
||||
email: newEmail,
|
||||
});
|
||||
|
||||
result = await oidc.runSignInUp({
|
||||
state: auth.state,
|
||||
});
|
||||
await oidc.confirmEmail(result.user);
|
||||
meResult = await execute({
|
||||
document: TestMeQuery,
|
||||
authToken: result.accessToken,
|
||||
}).then(r => r.expectNoGraphQLErrors());
|
||||
expect(meResult).toMatchObject({
|
||||
me: {
|
||||
email: newEmail,
|
||||
id: expect.any(String),
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test.concurrent(
|
||||
'User does not need to confirm their email if the domain is verified with the origanization',
|
||||
async ({ expect }) => {
|
||||
const seed = initSeed();
|
||||
const { createOrg } = await seed.createOwner();
|
||||
const { createOIDCIntegration } = await createOrg();
|
||||
|
||||
const { createMockServerAndUpdateIntegrationEndpoints, registerFakeDomain: registerDomain } =
|
||||
await createOIDCIntegration();
|
||||
const domain = await registerDomain();
|
||||
const oidc = await createMockServerAndUpdateIntegrationEndpoints();
|
||||
|
||||
const email = 'foo@' + domain;
|
||||
|
||||
let auth = await oidc.runGetAuthorizationUrl();
|
||||
|
||||
oidc.setUser({
|
||||
sub: 'test-user',
|
||||
email,
|
||||
});
|
||||
|
||||
const result = await oidc.runSignInUp({
|
||||
state: auth.state,
|
||||
});
|
||||
const meResult = await execute({
|
||||
document: TestMeQuery,
|
||||
authToken: result.accessToken,
|
||||
}).then(r => r.expectNoGraphQLErrors());
|
||||
expect(meResult).toMatchObject({
|
||||
me: {
|
||||
email,
|
||||
id: expect.any(String),
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
|
@ -430,6 +430,8 @@ export class SuperTokensUserAuthNStrategy extends AuthNStrategy<SuperTokensCooki
|
|||
}
|
||||
}
|
||||
|
||||
args.req.log.debug('the email is verified');
|
||||
|
||||
args.req.log.debug('SuperTokens session resolved.');
|
||||
return sessionData;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -323,6 +323,26 @@ export class SuperTokensStore {
|
|||
});
|
||||
}
|
||||
|
||||
async updateOIDCUserEmail(args: { userId: string; newEmail: string }) {
|
||||
const query = sql`
|
||||
UPDATE
|
||||
"supertokens_thirdparty_users"
|
||||
SET
|
||||
"email" = ${args.newEmail}
|
||||
WHERE
|
||||
"app_id" = 'public'
|
||||
AND "user_id" = ${args.userId}
|
||||
RETURNING
|
||||
"user_id" AS "userId"
|
||||
, "email" AS "email"
|
||||
, "third_party_id" AS "thirdPartyId"
|
||||
, "third_party_user_id" AS "thirdPartyUserId"
|
||||
, "time_joined" AS "timeJoined"
|
||||
`;
|
||||
|
||||
return await this.pool.maybeOne(query).then(ThirdpartUserModel.nullable().parse);
|
||||
}
|
||||
|
||||
async createThirdPartyUser(args: {
|
||||
email: string;
|
||||
thirdPartyId: string;
|
||||
|
|
|
|||
|
|
@ -1119,7 +1119,7 @@ export async function registerSupertokensAtHome(
|
|||
);
|
||||
return rep.status(200).send({
|
||||
status: 'SIGN_IN_UP_NOT_ALLOWED',
|
||||
reason: 'Sign in failed. Please contact your origanization administrator.',
|
||||
reason: 'Sign in failed. Please contact your organization administrator.',
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -1130,7 +1130,7 @@ export async function registerSupertokensAtHome(
|
|||
req.log.debug('received malformed json body from token endpoint');
|
||||
return rep.status(200).send({
|
||||
status: 'SIGN_IN_UP_NOT_ALLOWED',
|
||||
reason: 'Sign in failed. Please contact your origanization administrator.',
|
||||
reason: 'Sign in failed. Please contact your organization administrator.',
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -1140,7 +1140,7 @@ export async function registerSupertokensAtHome(
|
|||
req.log.debug('received invalid json body from token endpoint');
|
||||
return rep.status(200).send({
|
||||
status: 'SIGN_IN_UP_NOT_ALLOWED',
|
||||
reason: 'Sign in failed. Please contact your origanization administrator.',
|
||||
reason: 'Sign in failed. Please contact your organization administrator.',
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -1231,7 +1231,10 @@ export async function registerSupertokensAtHome(
|
|||
const current_url = new URL(env.hiveServices.webApp.url);
|
||||
current_url.pathname = '/auth/callback/oidc';
|
||||
|
||||
req.log.debug('attempt exchanging auth code for auth token');
|
||||
req.log.debug(
|
||||
'attempt exchanging auth code for auth token (endpoint=%s)',
|
||||
oidcIntegration.tokenEndpoint,
|
||||
);
|
||||
|
||||
broadcastLog(
|
||||
oidcIntegration.id,
|
||||
|
|
@ -1267,7 +1270,7 @@ export async function registerSupertokensAtHome(
|
|||
|
||||
return rep.status(200).send({
|
||||
status: 'SIGN_IN_UP_NOT_ALLOWED',
|
||||
reason: 'Sign in failed. Please contact your origanization administrator.',
|
||||
reason: 'Sign in failed. Please contact your organization administrator.',
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -1284,7 +1287,7 @@ export async function registerSupertokensAtHome(
|
|||
|
||||
return rep.status(200).send({
|
||||
status: 'SIGN_IN_UP_NOT_ALLOWED',
|
||||
reason: 'Sign in failed. Please contact your origanization administrator.',
|
||||
reason: 'Sign in failed. Please contact your organization administrator.',
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -1299,7 +1302,7 @@ export async function registerSupertokensAtHome(
|
|||
|
||||
return rep.status(200).send({
|
||||
status: 'SIGN_IN_UP_NOT_ALLOWED',
|
||||
reason: 'Sign in failed. Please contact your origanization administrator.',
|
||||
reason: 'Sign in failed. Please contact your organization administrator.',
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -1313,6 +1316,7 @@ export async function registerSupertokensAtHome(
|
|||
authorization: `Bearer ${codeGrantAccessToken}`,
|
||||
},
|
||||
});
|
||||
const userInfoBodyRaw = await userInfoResponse.text();
|
||||
|
||||
if (userInfoResponse.status != 200) {
|
||||
req.log.debug(
|
||||
|
|
@ -1321,16 +1325,15 @@ export async function registerSupertokensAtHome(
|
|||
);
|
||||
broadcastLog(
|
||||
oidcIntegration.id,
|
||||
`an unexpected error occured while calling the user info endoint '${oidcIntegration.userinfoEndpoint}'. HTTP Status: ${grantResponse.status} Body: ${await grantResponse.text()}.`,
|
||||
`an unexpected error occured while calling the user info endoint '${oidcIntegration.userinfoEndpoint}'. HTTP Status: ${userInfoResponse.status} HTTP Body: ${userInfoBodyRaw}.`,
|
||||
);
|
||||
|
||||
return rep.status(200).send({
|
||||
status: 'SIGN_IN_UP_NOT_ALLOWED',
|
||||
reason: 'Sign in failed. Please contact your origanization administrator.',
|
||||
reason: 'Sign in failed. Please contact your organization administrator.',
|
||||
});
|
||||
}
|
||||
|
||||
const userInfoBodyRaw = await userInfoResponse.text();
|
||||
const userInfoBodyJSON = parseJSONSafe(userInfoBodyRaw);
|
||||
|
||||
if (userInfoBodyJSON.type === 'error') {
|
||||
|
|
@ -1342,7 +1345,7 @@ export async function registerSupertokensAtHome(
|
|||
|
||||
return rep.status(200).send({
|
||||
status: 'SIGN_IN_UP_NOT_ALLOWED',
|
||||
reason: 'Sign in failed. Please contact your origanization administrator.',
|
||||
reason: 'Sign in failed. Please contact your organization administrator.',
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -1362,7 +1365,7 @@ export async function registerSupertokensAtHome(
|
|||
|
||||
return rep.status(200).send({
|
||||
status: 'SIGN_IN_UP_NOT_ALLOWED',
|
||||
reason: 'Sign in failed. Please contact your origanization administrator.',
|
||||
reason: 'Sign in failed. Please contact your organization administrator.',
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -1375,7 +1378,7 @@ export async function registerSupertokensAtHome(
|
|||
|
||||
return rep.status(200).send({
|
||||
status: 'SIGN_IN_UP_NOT_ALLOWED',
|
||||
reason: 'Sign in failed. Please contact your origanization administrator.',
|
||||
reason: 'Sign in failed. Please contact your organization administrator.',
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -1393,6 +1396,19 @@ export async function registerSupertokensAtHome(
|
|||
oidcIntegrationId: oidcIntegration.id,
|
||||
sub: userInfoBody.data.sub,
|
||||
});
|
||||
} else if (user.email !== userInfoBody.data.email) {
|
||||
req.log.debug('providers email has changed. Update record.');
|
||||
user = await supertokensStore.updateOIDCUserEmail({
|
||||
userId: user.userId,
|
||||
newEmail: userInfoBody.data.email,
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return rep.status(200).send({
|
||||
status: 'SIGN_IN_UP_NOT_ALLOWED',
|
||||
reason: 'Sign in failed. Please contact your organization administrator.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
req.log.debug('supertokens user provisioned. ensure hive user exists');
|
||||
|
|
|
|||
|
|
@ -3,6 +3,9 @@
|
|||
"type": "module",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@sentry/node": "^7.0.0",
|
||||
"@sentry/utils": "^7.0.0",
|
||||
|
|
|
|||
|
|
@ -758,6 +758,15 @@ export async function createStorage(
|
|||
action = 'created';
|
||||
}
|
||||
|
||||
if (internalUser.email !== email) {
|
||||
await t.query(sql`
|
||||
UPDATE "users"
|
||||
SET "email" = ${email}
|
||||
WHERE "id" = ${internalUser.id}
|
||||
`);
|
||||
internalUser.email = email;
|
||||
}
|
||||
|
||||
if (oidcIntegration !== null) {
|
||||
// Add user to OIDC linked integration
|
||||
await shared.addOrganizationMemberViaOIDCIntegrationId(
|
||||
|
|
|
|||
|
|
@ -352,6 +352,9 @@ importers:
|
|||
'@hive/server':
|
||||
specifier: workspace:*
|
||||
version: link:../packages/services/server
|
||||
'@hive/service-common':
|
||||
specifier: workspace:*
|
||||
version: link:../packages/services/service-common
|
||||
'@hive/storage':
|
||||
specifier: workspace:*
|
||||
version: link:../packages/services/storage
|
||||
|
|
@ -370,6 +373,9 @@ importers:
|
|||
'@types/dockerode':
|
||||
specifier: 3.3.43
|
||||
version: 3.3.43
|
||||
'@types/set-cookie-parser':
|
||||
specifier: 2.4.10
|
||||
version: 2.4.10
|
||||
async-retry:
|
||||
specifier: 1.3.3
|
||||
version: 1.3.3
|
||||
|
|
@ -400,6 +406,9 @@ importers:
|
|||
ioredis:
|
||||
specifier: 5.8.2
|
||||
version: 5.8.2
|
||||
set-cookie-parser:
|
||||
specifier: 2.7.1
|
||||
version: 2.7.1
|
||||
slonik:
|
||||
specifier: 30.4.4
|
||||
version: 30.4.4(patch_hash=195b140c0181c27a85a6026c0058087a419e38f6c5d89f5f2c608e39f5bf23e9)
|
||||
|
|
@ -10538,6 +10547,9 @@ packages:
|
|||
'@types/service-worker-mock@2.0.4':
|
||||
resolution: {integrity: sha512-MEBT2eiqYfhxjqYm/oAf2AvKLbPTPwJJAYrMdheKnGyz1yG9XBRfxCzi93h27qpSvI7jOYfXqFLVMLBXFDqo4A==}
|
||||
|
||||
'@types/set-cookie-parser@2.4.10':
|
||||
resolution: {integrity: sha512-GGmQVGpQWUe5qglJozEjZV/5dyxbOOZ0LHe/lqyWssB88Y4svNfst0uqBVscdDeIKl5Jy5+aPSvy7mI9tYRguw==}
|
||||
|
||||
'@types/shimmer@1.2.0':
|
||||
resolution: {integrity: sha512-UE7oxhQLLd9gub6JKIAhDq06T0F6FnztwMNRvYgjeQSBeMc1ZG/tA47EwfduvkuQS8apbkM/lpLpWsaCeYsXVg==}
|
||||
|
||||
|
|
@ -32881,6 +32893,10 @@ snapshots:
|
|||
|
||||
'@types/service-worker-mock@2.0.4': {}
|
||||
|
||||
'@types/set-cookie-parser@2.4.10':
|
||||
dependencies:
|
||||
'@types/node': 24.10.12
|
||||
|
||||
'@types/shimmer@1.2.0': {}
|
||||
|
||||
'@types/sinonjs__fake-timers@8.1.1': {}
|
||||
|
|
|
|||
Loading…
Reference in a new issue