116c04d8b2
Sits on `main` now that #23642 has merged. 18 files changed. ## Why Application tokens are stateless JWTs. When a user completes an OAuth `authorization_code` exchange, the server issues an access/refresh pair carrying `userId` as a claim and stores nothing. So today: - there is no record that a person ever authorized an app, hence nothing to list on a settings screen - there is no way for that person to take an app's access away. The only revocation that exists is uninstalling the app, which is workspace-wide and admin-only - `/oauth/revoke` accepted a refresh token, logged it and did nothing, because there was no state to change `client_credentials` is unaffected: no user is involved and it returns an access token with no refresh token. ## What **`core."applicationAuthorization"`**, one row per (user, application), unique on that pair so re-authorizing updates in place. Written at the `authorization_code` exchange, before the token pair is issued, so a refresh token is never handed out without the grant that makes it redeemable. A dedicated table rather than a new `AppTokenType`: this is a grant keyed on identity, not a token keyed on a secret, and `appToken` is already overloaded. FKs to user, workspace, application and userWorkspace all cascade, which covers hard deletes. Membership removal soft-deletes the `userWorkspace` row, so that cascade does not fire and the grant outlives the membership. The refresh path therefore rechecks membership on every renewal rather than trusting the row's existence. **Enforcement.** `refresh_token` checks the row when the token carries a user, and returns `invalid_grant` if it is revoked. Revoking does not kill live access tokens, so access ends within one access-token window (`APPLICATION_ACCESS_TOKEN_EXPIRES_IN`, 30 minutes) rather than instantly. The alternative is a DB read on every API request, which is not worth it for a 30 minute tail; the UI should say so. **RFC 7009 revocation now revokes.** Revoking a refresh token revokes the authorization behind it. It also now checks the token was issued to the client asking, which it never did before. That check did not matter while revocation was a no-op; it does now. **Introspection** reports a refresh token inactive once its authorization is revoked. Access tokens keep reporting active until they expire, because they genuinely still work. **API:** `currentUserApplicationAuthorizations` and `revokeApplicationAuthorization`, both behind `UserAuthGuard`. The mutation scopes by `userId` inside the `UPDATE` rather than read-then-write, so one user cannot revoke another's authorization by guessing an id. ## Backwards compatibility Refresh tokens already in the wild have no row. Rejecting them would sign every live integration out on deploy, so the first refresh backfills the grant that was always implied. A revoked authorization keeps its row, so this never resurrects access someone turned off, and the backfill is insert-only so it cannot overwrite a real consent. If the user has since left the workspace, the refresh fails instead. Those tokens carry no scope claim and no record of when consent was given, so `scopes` and `lastAuthorizedAt` are nullable and left null on a backfilled row. Null means "the original consent is not on record" rather than a guess assembled from what the application declares today; a real re-authorization fills both in. Revoking such a token lays the row down before marking it, so the revocation sticks instead of being undone by the next refresh. ## Not in this PR The settings UI, following how #23643 shipped the sessions API and #23645 the devices screen. Introspection still reports a refresh token active once the membership is gone. That matches access tokens, which genuinely keep working in that case, so closing it belongs with the wider question of validating membership on every application-token request. ## Testing - 29 unit tests across the authorization service and the three OAuth grant paths - 9 integration tests on `/oauth/token`, `/oauth/revoke` and the GraphQL API: scopes as granted are recorded, revoking blocks the next refresh, re-authorizing reinstates, a pre-record token backfills without inventing a consent, a revoked pre-record token stays revoked, the authorization is listed to the user who granted it, revoking from that list stops the refresh token being redeemed, a repeated revocation reports no-op, and another user can neither see nor revoke it - the cross-user isolation and revoke-from-list tests are mutation-checked: dropping the `userId` scoping from `revokeAuthorizationById` fails only the isolation test, and disabling the `revokedAt` check in `oauth.service.ts` fails the revoke-from-list test plus two pre-existing ones - full `twenty-server` suite green - instance command applied against a fresh `database:reset`, table/index/FK shape verified against `information_schema` Closes part of https://github.com/twentyhq/core-team-issues/issues/2747 --------- Co-authored-by: prastoin <45004772+prastoin@users.noreply.github.com>
1183 lines
36 KiB
TypeScript
1183 lines
36 KiB
TypeScript
import crypto from 'crypto';
|
|
|
|
import bcrypt from 'bcrypt';
|
|
import gql from 'graphql-tag';
|
|
import request from 'supertest';
|
|
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
|
|
import { base64UrlEncode } from 'twenty-shared/utils';
|
|
import { type DataSource } from 'typeorm';
|
|
|
|
import { AppTokenType } from 'src/engine/core-modules/app-token/app-token.entity';
|
|
import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
|
|
import { USER_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-users.util';
|
|
|
|
const TEST_WORKSPACE_ID = SEED_APPLE_WORKSPACE_ID;
|
|
const TEST_USER_ID = USER_DATA_SEED_IDS.JANE;
|
|
|
|
type TestRegistration = {
|
|
id: string;
|
|
universalIdentifier: string;
|
|
name: string;
|
|
oAuthClientId: string;
|
|
oAuthRedirectUris: string[];
|
|
oAuthScopes: string[];
|
|
};
|
|
|
|
type TestApplication = {
|
|
id: string;
|
|
};
|
|
|
|
const insertRegistration = async (
|
|
ds: DataSource,
|
|
params: {
|
|
name: string;
|
|
clientSecretHash: string | null;
|
|
redirectUris: string[];
|
|
scopes: string[];
|
|
},
|
|
): Promise<TestRegistration> => {
|
|
const id = crypto.randomUUID();
|
|
const universalIdentifier = crypto.randomUUID();
|
|
const oAuthClientId = crypto.randomUUID();
|
|
|
|
await ds.query(
|
|
`INSERT INTO core."applicationRegistration"
|
|
(id, "universalIdentifier", name, "oAuthClientId", "oAuthClientSecretHash", "oAuthRedirectUris", "oAuthScopes", "workspaceId")
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
|
[
|
|
id,
|
|
universalIdentifier,
|
|
params.name,
|
|
oAuthClientId,
|
|
params.clientSecretHash,
|
|
params.redirectUris,
|
|
params.scopes,
|
|
TEST_WORKSPACE_ID,
|
|
],
|
|
);
|
|
|
|
return {
|
|
id,
|
|
universalIdentifier,
|
|
name: params.name,
|
|
oAuthClientId,
|
|
oAuthRedirectUris: params.redirectUris,
|
|
oAuthScopes: params.scopes,
|
|
};
|
|
};
|
|
|
|
const insertApplication = async (
|
|
ds: DataSource,
|
|
params: {
|
|
universalIdentifier: string;
|
|
name: string;
|
|
workspaceId: string;
|
|
applicationRegistrationId: string;
|
|
},
|
|
): Promise<TestApplication> => {
|
|
const id = crypto.randomUUID();
|
|
|
|
await ds.query(
|
|
`INSERT INTO core."application"
|
|
(id, "universalIdentifier", name, "workspaceId", "applicationRegistrationId", "sourceType", "sourcePath", "canBeUninstalled")
|
|
VALUES ($1, $2, $3, $4, $5, 'local', '', true)`,
|
|
[
|
|
id,
|
|
params.universalIdentifier,
|
|
params.name,
|
|
params.workspaceId,
|
|
params.applicationRegistrationId,
|
|
],
|
|
);
|
|
|
|
return { id };
|
|
};
|
|
|
|
const insertAppToken = async (
|
|
ds: DataSource,
|
|
params: {
|
|
value: string;
|
|
type: AppTokenType;
|
|
userId: string;
|
|
workspaceId: string;
|
|
expiresAt: Date;
|
|
context?: Record<string, string>;
|
|
},
|
|
): Promise<string> => {
|
|
const rows = await ds.query(
|
|
`INSERT INTO core."appToken"
|
|
(value, type, "userId", "workspaceId", "expiresAt", context)
|
|
VALUES ($1, $2, $3, $4, $5, $6)
|
|
RETURNING id`,
|
|
[
|
|
params.value,
|
|
params.type,
|
|
params.userId,
|
|
params.workspaceId,
|
|
params.expiresAt,
|
|
params.context ? JSON.stringify(params.context) : null,
|
|
],
|
|
);
|
|
|
|
return rows[0].id;
|
|
};
|
|
|
|
describe('OAuth (integration)', () => {
|
|
const baseUrl = `http://localhost:${APP_PORT}`;
|
|
|
|
let ds: DataSource;
|
|
|
|
let testRegistration: TestRegistration;
|
|
let testClientSecret: string;
|
|
let testApplication: TestApplication;
|
|
|
|
let publicRegistration: TestRegistration;
|
|
|
|
let autoInstallRegistration: TestRegistration;
|
|
let autoInstallClientSecret: string;
|
|
|
|
const createdEntityIds: {
|
|
registrations: string[];
|
|
tokens: string[];
|
|
applications: string[];
|
|
} = { registrations: [], tokens: [], applications: [] };
|
|
|
|
beforeAll(async () => {
|
|
ds = global.testDataSource;
|
|
|
|
testClientSecret = crypto.randomBytes(32).toString('hex');
|
|
const clientSecretHash = await bcrypt.hash(testClientSecret, 10);
|
|
|
|
testRegistration = await insertRegistration(ds, {
|
|
name: 'OAuth Integration Test App',
|
|
clientSecretHash,
|
|
redirectUris: ['https://example.com/callback'],
|
|
scopes: ['read', 'write'],
|
|
});
|
|
createdEntityIds.registrations.push(testRegistration.id);
|
|
|
|
testApplication = await insertApplication(ds, {
|
|
universalIdentifier: testRegistration.universalIdentifier,
|
|
name: testRegistration.name,
|
|
workspaceId: TEST_WORKSPACE_ID,
|
|
applicationRegistrationId: testRegistration.id,
|
|
});
|
|
|
|
publicRegistration = await insertRegistration(ds, {
|
|
name: 'OAuth Public PKCE Test App',
|
|
clientSecretHash: null,
|
|
redirectUris: ['https://example.com/callback'],
|
|
scopes: ['read', 'write'],
|
|
});
|
|
createdEntityIds.registrations.push(publicRegistration.id);
|
|
|
|
const publicApplication = await insertApplication(ds, {
|
|
universalIdentifier: publicRegistration.universalIdentifier,
|
|
name: publicRegistration.name,
|
|
workspaceId: TEST_WORKSPACE_ID,
|
|
applicationRegistrationId: publicRegistration.id,
|
|
});
|
|
createdEntityIds.applications.push(publicApplication.id);
|
|
|
|
autoInstallClientSecret = crypto.randomBytes(32).toString('hex');
|
|
const autoInstallSecretHash = await bcrypt.hash(
|
|
autoInstallClientSecret,
|
|
10,
|
|
);
|
|
|
|
autoInstallRegistration = await insertRegistration(ds, {
|
|
name: 'OAuth Auto-Install Test App',
|
|
clientSecretHash: autoInstallSecretHash,
|
|
redirectUris: ['https://example.com/callback'],
|
|
scopes: ['api'],
|
|
});
|
|
createdEntityIds.registrations.push(autoInstallRegistration.id);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
if (createdEntityIds.tokens.length > 0) {
|
|
const placeholders = createdEntityIds.tokens
|
|
.map((_, i) => `$${i + 1}`)
|
|
.join(', ');
|
|
|
|
await ds.query(
|
|
`DELETE FROM core."appToken" WHERE id IN (${placeholders})`,
|
|
createdEntityIds.tokens,
|
|
);
|
|
}
|
|
|
|
if (createdEntityIds.applications.length > 0) {
|
|
const placeholders = createdEntityIds.applications
|
|
.map((_, i) => `$${i + 1}`)
|
|
.join(', ');
|
|
|
|
await ds.query(
|
|
`DELETE FROM core."application" WHERE id IN (${placeholders})`,
|
|
createdEntityIds.applications,
|
|
);
|
|
}
|
|
|
|
if (testApplication) {
|
|
await ds.query(`DELETE FROM core."application" WHERE id = $1`, [
|
|
testApplication.id,
|
|
]);
|
|
}
|
|
|
|
if (createdEntityIds.registrations.length > 0) {
|
|
const placeholders = createdEntityIds.registrations
|
|
.map((_, i) => `$${i + 1}`)
|
|
.join(', ');
|
|
|
|
await ds.query(
|
|
`DELETE FROM core."applicationRegistration" WHERE id IN (${placeholders})`,
|
|
createdEntityIds.registrations,
|
|
);
|
|
}
|
|
});
|
|
|
|
const postToken = (body: Record<string, string>) =>
|
|
request(baseUrl).post('/oauth/token').send(body);
|
|
|
|
describe('Discovery endpoint', () => {
|
|
it('should return OAuth authorization server metadata', async () => {
|
|
const res = await request(baseUrl)
|
|
.get('/.well-known/oauth-authorization-server')
|
|
.expect(200);
|
|
|
|
expect(res.body.token_endpoint).toContain('/oauth/token');
|
|
expect(res.body.revocation_endpoint).toContain('/oauth/revoke');
|
|
expect(res.body.introspection_endpoint).toContain('/oauth/introspect');
|
|
expect(res.body.grant_types_supported).toEqual(
|
|
expect.arrayContaining([
|
|
'authorization_code',
|
|
'client_credentials',
|
|
'refresh_token',
|
|
]),
|
|
);
|
|
expect(res.body.code_challenge_methods_supported).toContain('S256');
|
|
expect(res.body.scopes_supported).toBeDefined();
|
|
expect(res.body.response_types_supported).toContain('code');
|
|
});
|
|
});
|
|
|
|
describe('Token endpoint validation', () => {
|
|
it('should return 400 for unsupported grant_type', async () => {
|
|
const res = await postToken({
|
|
grant_type: 'password',
|
|
client_id: testRegistration.oAuthClientId,
|
|
}).expect(400);
|
|
|
|
expect(res.body.error).toBe('unsupported_grant_type');
|
|
});
|
|
|
|
it('should return 401 for invalid client_id', async () => {
|
|
const res = await postToken({
|
|
grant_type: 'client_credentials',
|
|
client_id: 'non-existent-client',
|
|
client_secret: testClientSecret,
|
|
}).expect(401);
|
|
|
|
expect(res.body.error).toBe('invalid_client');
|
|
});
|
|
|
|
it('should return 401 for invalid client_secret', async () => {
|
|
const res = await postToken({
|
|
grant_type: 'client_credentials',
|
|
client_id: testRegistration.oAuthClientId,
|
|
client_secret: 'wrong-secret',
|
|
}).expect(401);
|
|
|
|
expect(res.body.error).toBe('invalid_client');
|
|
});
|
|
|
|
it('should include Cache-Control: no-store header on responses', async () => {
|
|
const res = await postToken({
|
|
grant_type: 'client_credentials',
|
|
client_id: testRegistration.oAuthClientId,
|
|
client_secret: testClientSecret,
|
|
}).expect(200);
|
|
|
|
expect(res.headers['cache-control']).toBe('no-store');
|
|
expect(res.headers['pragma']).toBe('no-cache');
|
|
});
|
|
|
|
it('should return 400 when grant_type is missing', async () => {
|
|
await postToken({
|
|
client_id: testRegistration.oAuthClientId,
|
|
}).expect(400);
|
|
});
|
|
});
|
|
|
|
describe('Client credentials grant', () => {
|
|
it('should issue an access token for valid credentials', async () => {
|
|
const res = await postToken({
|
|
grant_type: 'client_credentials',
|
|
client_id: testRegistration.oAuthClientId,
|
|
client_secret: testClientSecret,
|
|
}).expect(200);
|
|
|
|
expect(res.body.access_token).toBeDefined();
|
|
expect(res.body.token_type).toBe('Bearer');
|
|
expect(res.body.expires_in).toBeGreaterThan(0);
|
|
expect(res.body.scope).toBe('read write');
|
|
expect(res.body.refresh_token).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('Authorization code grant', () => {
|
|
const createAuthorizationCode = async (
|
|
clientId: string,
|
|
redirectUri = 'https://example.com/callback',
|
|
): Promise<string> => {
|
|
const code = crypto.randomBytes(42).toString('hex');
|
|
const hashedCode = crypto.createHash('sha256').update(code).digest('hex');
|
|
|
|
const tokenId = await insertAppToken(ds, {
|
|
value: hashedCode,
|
|
type: AppTokenType.AuthorizationCode,
|
|
userId: TEST_USER_ID,
|
|
workspaceId: TEST_WORKSPACE_ID,
|
|
expiresAt: new Date(Date.now() + 5 * 60 * 1000),
|
|
context: { redirectUri, clientId },
|
|
});
|
|
|
|
createdEntityIds.tokens.push(tokenId);
|
|
|
|
return code;
|
|
};
|
|
|
|
it('should exchange a valid authorization code for tokens', async () => {
|
|
const code = await createAuthorizationCode(
|
|
testRegistration.oAuthClientId,
|
|
);
|
|
|
|
const res = await postToken({
|
|
grant_type: 'authorization_code',
|
|
code,
|
|
client_id: testRegistration.oAuthClientId,
|
|
client_secret: testClientSecret,
|
|
redirect_uri: 'https://example.com/callback',
|
|
}).expect(200);
|
|
|
|
expect(res.body.access_token).toBeDefined();
|
|
expect(res.body.refresh_token).toBeDefined();
|
|
expect(res.body.token_type).toBe('Bearer');
|
|
expect(res.body.expires_in).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('should reject a reused authorization code', async () => {
|
|
const code = await createAuthorizationCode(
|
|
testRegistration.oAuthClientId,
|
|
);
|
|
|
|
await postToken({
|
|
grant_type: 'authorization_code',
|
|
code,
|
|
client_id: testRegistration.oAuthClientId,
|
|
client_secret: testClientSecret,
|
|
redirect_uri: 'https://example.com/callback',
|
|
}).expect(200);
|
|
|
|
const res = await postToken({
|
|
grant_type: 'authorization_code',
|
|
code,
|
|
client_id: testRegistration.oAuthClientId,
|
|
client_secret: testClientSecret,
|
|
redirect_uri: 'https://example.com/callback',
|
|
}).expect(400);
|
|
|
|
expect(res.body.error).toBe('invalid_grant');
|
|
});
|
|
|
|
it('should reject an expired authorization code', async () => {
|
|
const code = crypto.randomBytes(42).toString('hex');
|
|
const hashedCode = crypto.createHash('sha256').update(code).digest('hex');
|
|
|
|
const tokenId = await insertAppToken(ds, {
|
|
value: hashedCode,
|
|
type: AppTokenType.AuthorizationCode,
|
|
userId: TEST_USER_ID,
|
|
workspaceId: TEST_WORKSPACE_ID,
|
|
expiresAt: new Date(Date.now() - 1000),
|
|
context: { clientId: testRegistration.oAuthClientId },
|
|
});
|
|
|
|
createdEntityIds.tokens.push(tokenId);
|
|
|
|
const res = await postToken({
|
|
grant_type: 'authorization_code',
|
|
code,
|
|
client_id: testRegistration.oAuthClientId,
|
|
client_secret: testClientSecret,
|
|
redirect_uri: 'https://example.com/callback',
|
|
}).expect(400);
|
|
|
|
expect(res.body.error).toBe('invalid_grant');
|
|
});
|
|
|
|
it('should reject when redirect_uri does not match', async () => {
|
|
const code = await createAuthorizationCode(
|
|
testRegistration.oAuthClientId,
|
|
);
|
|
|
|
const res = await postToken({
|
|
grant_type: 'authorization_code',
|
|
code,
|
|
client_id: testRegistration.oAuthClientId,
|
|
client_secret: testClientSecret,
|
|
redirect_uri: 'https://evil.com/callback',
|
|
}).expect(400);
|
|
|
|
expect(res.body.error).toBe('invalid_grant');
|
|
});
|
|
|
|
it('should reject when auth code was issued to a different client', async () => {
|
|
const code = await createAuthorizationCode(
|
|
autoInstallRegistration.oAuthClientId,
|
|
);
|
|
|
|
const res = await postToken({
|
|
grant_type: 'authorization_code',
|
|
code,
|
|
client_id: testRegistration.oAuthClientId,
|
|
client_secret: testClientSecret,
|
|
redirect_uri: 'https://example.com/callback',
|
|
}).expect(400);
|
|
|
|
expect(res.body.error).toBe('invalid_grant');
|
|
expect(res.body.error_description).toContain('not issued to this client');
|
|
});
|
|
|
|
it('should require either client_secret or code_verifier', async () => {
|
|
const code = await createAuthorizationCode(
|
|
publicRegistration.oAuthClientId,
|
|
);
|
|
|
|
const res = await postToken({
|
|
grant_type: 'authorization_code',
|
|
code,
|
|
client_id: publicRegistration.oAuthClientId,
|
|
redirect_uri: 'https://example.com/callback',
|
|
}).expect(400);
|
|
|
|
expect(res.body.error).toBe('invalid_request');
|
|
});
|
|
});
|
|
|
|
describe('Authorization code grant with PKCE', () => {
|
|
const createAuthCodeWithPkce = async (
|
|
clientId: string,
|
|
): Promise<{
|
|
code: string;
|
|
codeVerifier: string;
|
|
}> => {
|
|
const codeVerifier = crypto.randomBytes(32).toString('hex');
|
|
const codeChallenge = base64UrlEncode(
|
|
crypto.createHash('sha256').update(codeVerifier).digest(),
|
|
);
|
|
|
|
const code = crypto.randomBytes(42).toString('hex');
|
|
const hashedCode = crypto.createHash('sha256').update(code).digest('hex');
|
|
|
|
const codeTokenId = await insertAppToken(ds, {
|
|
value: hashedCode,
|
|
type: AppTokenType.AuthorizationCode,
|
|
userId: TEST_USER_ID,
|
|
workspaceId: TEST_WORKSPACE_ID,
|
|
expiresAt: new Date(Date.now() + 5 * 60 * 1000),
|
|
context: {
|
|
redirectUri: 'https://example.com/callback',
|
|
clientId,
|
|
codeChallenge,
|
|
},
|
|
});
|
|
|
|
createdEntityIds.tokens.push(codeTokenId);
|
|
|
|
return { code, codeVerifier };
|
|
};
|
|
|
|
it('should exchange code with valid PKCE verifier', async () => {
|
|
const { code, codeVerifier } = await createAuthCodeWithPkce(
|
|
publicRegistration.oAuthClientId,
|
|
);
|
|
|
|
const res = await postToken({
|
|
grant_type: 'authorization_code',
|
|
code,
|
|
client_id: publicRegistration.oAuthClientId,
|
|
code_verifier: codeVerifier,
|
|
redirect_uri: 'https://example.com/callback',
|
|
}).expect(200);
|
|
|
|
expect(res.body.access_token).toBeDefined();
|
|
expect(res.body.refresh_token).toBeDefined();
|
|
expect(res.body.token_type).toBe('Bearer');
|
|
});
|
|
|
|
it('should reject code with wrong PKCE verifier', async () => {
|
|
const { code } = await createAuthCodeWithPkce(
|
|
publicRegistration.oAuthClientId,
|
|
);
|
|
|
|
const res = await postToken({
|
|
grant_type: 'authorization_code',
|
|
code,
|
|
client_id: publicRegistration.oAuthClientId,
|
|
code_verifier: 'wrong-verifier',
|
|
redirect_uri: 'https://example.com/callback',
|
|
}).expect(400);
|
|
|
|
expect(res.body.error).toBe('invalid_grant');
|
|
});
|
|
|
|
it('should require code_verifier when PKCE was used in authorization', async () => {
|
|
const { code } = await createAuthCodeWithPkce(
|
|
publicRegistration.oAuthClientId,
|
|
);
|
|
|
|
const res = await postToken({
|
|
grant_type: 'authorization_code',
|
|
code,
|
|
client_id: publicRegistration.oAuthClientId,
|
|
redirect_uri: 'https://example.com/callback',
|
|
}).expect(400);
|
|
|
|
expect(res.body.error).toBe('invalid_request');
|
|
expect(res.body.error_description).toContain('code_verifier is required');
|
|
});
|
|
|
|
it('should reject a confidential client that presents only PKCE and no client_secret', async () => {
|
|
const { code, codeVerifier } = await createAuthCodeWithPkce(
|
|
testRegistration.oAuthClientId,
|
|
);
|
|
|
|
const res = await postToken({
|
|
grant_type: 'authorization_code',
|
|
code,
|
|
client_id: testRegistration.oAuthClientId,
|
|
code_verifier: codeVerifier,
|
|
redirect_uri: 'https://example.com/callback',
|
|
}).expect(401);
|
|
|
|
expect(res.body.error).toBe('invalid_client');
|
|
});
|
|
});
|
|
|
|
describe('OAuth auto-install', () => {
|
|
const createAutoInstallAuthCode = async (): Promise<string> => {
|
|
const code = crypto.randomBytes(42).toString('hex');
|
|
const hashedCode = crypto.createHash('sha256').update(code).digest('hex');
|
|
|
|
const tokenId = await insertAppToken(ds, {
|
|
value: hashedCode,
|
|
type: AppTokenType.AuthorizationCode,
|
|
userId: TEST_USER_ID,
|
|
workspaceId: TEST_WORKSPACE_ID,
|
|
expiresAt: new Date(Date.now() + 5 * 60 * 1000),
|
|
context: {
|
|
redirectUri: 'https://example.com/callback',
|
|
clientId: autoInstallRegistration.oAuthClientId,
|
|
},
|
|
});
|
|
|
|
createdEntityIds.tokens.push(tokenId);
|
|
|
|
return code;
|
|
};
|
|
|
|
it('should auto-install application during authorization code exchange', async () => {
|
|
const code = await createAutoInstallAuthCode();
|
|
|
|
const res = await postToken({
|
|
grant_type: 'authorization_code',
|
|
code,
|
|
client_id: autoInstallRegistration.oAuthClientId,
|
|
client_secret: autoInstallClientSecret,
|
|
redirect_uri: 'https://example.com/callback',
|
|
}).expect(200);
|
|
|
|
expect(res.body.access_token).toBeDefined();
|
|
expect(res.body.refresh_token).toBeDefined();
|
|
expect(res.body.token_type).toBe('Bearer');
|
|
expect(res.body.scope).toBe('api');
|
|
|
|
const rows = await ds.query(
|
|
`SELECT id, name, description, "sourcePath", "universalIdentifier"
|
|
FROM core."application"
|
|
WHERE "applicationRegistrationId" = $1
|
|
AND "workspaceId" = $2`,
|
|
[autoInstallRegistration.id, TEST_WORKSPACE_ID],
|
|
);
|
|
|
|
expect(rows).toHaveLength(1);
|
|
|
|
const autoCreatedApp = rows[0];
|
|
|
|
expect(autoCreatedApp.name).toBe('OAuth Auto-Install Test App');
|
|
expect(autoCreatedApp.sourcePath).toBe('oauth-install');
|
|
expect(autoCreatedApp.universalIdentifier).toBe(
|
|
autoInstallRegistration.universalIdentifier,
|
|
);
|
|
|
|
createdEntityIds.applications.push(autoCreatedApp.id);
|
|
});
|
|
|
|
it('should reuse existing application on subsequent authorization code exchanges', async () => {
|
|
const code = await createAutoInstallAuthCode();
|
|
|
|
const res = await postToken({
|
|
grant_type: 'authorization_code',
|
|
code,
|
|
client_id: autoInstallRegistration.oAuthClientId,
|
|
client_secret: autoInstallClientSecret,
|
|
redirect_uri: 'https://example.com/callback',
|
|
}).expect(200);
|
|
|
|
expect(res.body.access_token).toBeDefined();
|
|
|
|
const rows = await ds.query(
|
|
`SELECT id FROM core."application"
|
|
WHERE "applicationRegistrationId" = $1
|
|
AND "workspaceId" = $2`,
|
|
[autoInstallRegistration.id, TEST_WORKSPACE_ID],
|
|
);
|
|
|
|
expect(rows).toHaveLength(1);
|
|
});
|
|
|
|
it('should fail client credentials when app is not installed in any workspace', async () => {
|
|
const noInstallSecret = crypto.randomBytes(32).toString('hex');
|
|
const noInstallHash = await bcrypt.hash(noInstallSecret, 10);
|
|
|
|
const noInstallRegistration = await insertRegistration(ds, {
|
|
name: 'No Install Test App',
|
|
clientSecretHash: noInstallHash,
|
|
redirectUris: [],
|
|
scopes: ['api'],
|
|
});
|
|
|
|
createdEntityIds.registrations.push(noInstallRegistration.id);
|
|
|
|
const res = await postToken({
|
|
grant_type: 'client_credentials',
|
|
client_id: noInstallRegistration.oAuthClientId,
|
|
client_secret: noInstallSecret,
|
|
}).expect(400);
|
|
|
|
expect(res.body.error).toBe('server_error');
|
|
expect(res.body.error_description).toContain(
|
|
'No workspace installation found',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('Refresh token grant', () => {
|
|
const createRefreshTokenAuthCode = async (
|
|
clientId: string,
|
|
): Promise<string> => {
|
|
const code = crypto.randomBytes(42).toString('hex');
|
|
const hashedCode = crypto.createHash('sha256').update(code).digest('hex');
|
|
|
|
const tokenId = await insertAppToken(ds, {
|
|
value: hashedCode,
|
|
type: AppTokenType.AuthorizationCode,
|
|
userId: TEST_USER_ID,
|
|
workspaceId: TEST_WORKSPACE_ID,
|
|
expiresAt: new Date(Date.now() + 5 * 60 * 1000),
|
|
context: {
|
|
redirectUri: 'https://example.com/callback',
|
|
clientId,
|
|
},
|
|
});
|
|
|
|
createdEntityIds.tokens.push(tokenId);
|
|
|
|
return code;
|
|
};
|
|
|
|
it('should issue new tokens from a valid refresh token', async () => {
|
|
const code = await createRefreshTokenAuthCode(
|
|
testRegistration.oAuthClientId,
|
|
);
|
|
|
|
const authCodeRes = await postToken({
|
|
grant_type: 'authorization_code',
|
|
code,
|
|
client_id: testRegistration.oAuthClientId,
|
|
client_secret: testClientSecret,
|
|
redirect_uri: 'https://example.com/callback',
|
|
}).expect(200);
|
|
|
|
const refreshToken = authCodeRes.body.refresh_token;
|
|
|
|
expect(refreshToken).toBeDefined();
|
|
|
|
const res = await postToken({
|
|
grant_type: 'refresh_token',
|
|
refresh_token: refreshToken,
|
|
client_id: testRegistration.oAuthClientId,
|
|
client_secret: testClientSecret,
|
|
}).expect(200);
|
|
|
|
expect(res.body.access_token).toBeDefined();
|
|
expect(res.body.refresh_token).toBeDefined();
|
|
expect(res.body.token_type).toBe('Bearer');
|
|
expect(res.body.expires_in).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('should reject refresh token presented by a different client', async () => {
|
|
const code = await createRefreshTokenAuthCode(
|
|
testRegistration.oAuthClientId,
|
|
);
|
|
|
|
const authCodeRes = await postToken({
|
|
grant_type: 'authorization_code',
|
|
code,
|
|
client_id: testRegistration.oAuthClientId,
|
|
client_secret: testClientSecret,
|
|
redirect_uri: 'https://example.com/callback',
|
|
}).expect(200);
|
|
|
|
const refreshToken = authCodeRes.body.refresh_token;
|
|
|
|
const res = await postToken({
|
|
grant_type: 'refresh_token',
|
|
refresh_token: refreshToken,
|
|
client_id: autoInstallRegistration.oAuthClientId,
|
|
client_secret: autoInstallClientSecret,
|
|
}).expect(400);
|
|
|
|
expect(res.body.error).toBe('invalid_grant');
|
|
expect(res.body.error_description).toContain('not issued to this client');
|
|
});
|
|
|
|
it('should reject an invalid refresh token', async () => {
|
|
const res = await postToken({
|
|
grant_type: 'refresh_token',
|
|
refresh_token: 'invalid-refresh-token',
|
|
client_id: testRegistration.oAuthClientId,
|
|
client_secret: testClientSecret,
|
|
}).expect(400);
|
|
|
|
expect(res.body.error).toBe('invalid_grant');
|
|
});
|
|
});
|
|
|
|
describe('Authorization code replay detection', () => {
|
|
it('should return specific error when a used code is replayed', async () => {
|
|
const code = crypto.randomBytes(42).toString('hex');
|
|
const hashedCode = crypto.createHash('sha256').update(code).digest('hex');
|
|
|
|
const tokenId = await insertAppToken(ds, {
|
|
value: hashedCode,
|
|
type: AppTokenType.AuthorizationCode,
|
|
userId: TEST_USER_ID,
|
|
workspaceId: TEST_WORKSPACE_ID,
|
|
expiresAt: new Date(Date.now() + 5 * 60 * 1000),
|
|
context: {
|
|
redirectUri: 'https://example.com/callback',
|
|
clientId: testRegistration.oAuthClientId,
|
|
},
|
|
});
|
|
|
|
createdEntityIds.tokens.push(tokenId);
|
|
|
|
// First use succeeds
|
|
await postToken({
|
|
grant_type: 'authorization_code',
|
|
code,
|
|
client_id: testRegistration.oAuthClientId,
|
|
client_secret: testClientSecret,
|
|
redirect_uri: 'https://example.com/callback',
|
|
}).expect(200);
|
|
|
|
// Second use detects replay
|
|
const res = await postToken({
|
|
grant_type: 'authorization_code',
|
|
code,
|
|
client_id: testRegistration.oAuthClientId,
|
|
client_secret: testClientSecret,
|
|
redirect_uri: 'https://example.com/callback',
|
|
}).expect(400);
|
|
|
|
expect(res.body.error).toBe('invalid_grant');
|
|
expect(res.body.error_description).toContain('already been used');
|
|
});
|
|
});
|
|
|
|
describe('Token revocation endpoint', () => {
|
|
it('should return 200 for valid token revocation', async () => {
|
|
const code = crypto.randomBytes(42).toString('hex');
|
|
const hashedCode = crypto.createHash('sha256').update(code).digest('hex');
|
|
|
|
const tokenId = await insertAppToken(ds, {
|
|
value: hashedCode,
|
|
type: AppTokenType.AuthorizationCode,
|
|
userId: TEST_USER_ID,
|
|
workspaceId: TEST_WORKSPACE_ID,
|
|
expiresAt: new Date(Date.now() + 5 * 60 * 1000),
|
|
context: {
|
|
redirectUri: 'https://example.com/callback',
|
|
clientId: testRegistration.oAuthClientId,
|
|
},
|
|
});
|
|
|
|
createdEntityIds.tokens.push(tokenId);
|
|
|
|
const tokenRes = await postToken({
|
|
grant_type: 'authorization_code',
|
|
code,
|
|
client_id: testRegistration.oAuthClientId,
|
|
client_secret: testClientSecret,
|
|
redirect_uri: 'https://example.com/callback',
|
|
}).expect(200);
|
|
|
|
await request(baseUrl)
|
|
.post('/oauth/revoke')
|
|
.send({
|
|
token: tokenRes.body.refresh_token,
|
|
client_id: testRegistration.oAuthClientId,
|
|
client_secret: testClientSecret,
|
|
})
|
|
.expect(200);
|
|
});
|
|
|
|
it('should return 200 for invalid token (per RFC 7009)', async () => {
|
|
await request(baseUrl)
|
|
.post('/oauth/revoke')
|
|
.send({
|
|
token: 'completely-invalid-token',
|
|
client_id: testRegistration.oAuthClientId,
|
|
client_secret: testClientSecret,
|
|
})
|
|
.expect(200);
|
|
});
|
|
});
|
|
|
|
describe('Token introspection endpoint', () => {
|
|
it('should return active=true for a valid access token', async () => {
|
|
const res = await postToken({
|
|
grant_type: 'client_credentials',
|
|
client_id: testRegistration.oAuthClientId,
|
|
client_secret: testClientSecret,
|
|
}).expect(200);
|
|
|
|
const introspectRes = await request(baseUrl)
|
|
.post('/oauth/introspect')
|
|
.send({
|
|
token: res.body.access_token,
|
|
client_id: testRegistration.oAuthClientId,
|
|
client_secret: testClientSecret,
|
|
})
|
|
.expect(200);
|
|
|
|
expect(introspectRes.body.active).toBe(true);
|
|
expect(introspectRes.body.client_id).toBe(testRegistration.oAuthClientId);
|
|
expect(introspectRes.body.token_type).toBe('Bearer');
|
|
});
|
|
|
|
it('should return active=false for an invalid token', async () => {
|
|
const res = await request(baseUrl)
|
|
.post('/oauth/introspect')
|
|
.send({
|
|
token: 'invalid-token',
|
|
client_id: testRegistration.oAuthClientId,
|
|
client_secret: testClientSecret,
|
|
})
|
|
.expect(200);
|
|
|
|
expect(res.body.active).toBe(false);
|
|
});
|
|
|
|
it('should require client_id', async () => {
|
|
await request(baseUrl)
|
|
.post('/oauth/introspect')
|
|
.send({ token: 'some-token' })
|
|
.expect(401);
|
|
});
|
|
});
|
|
|
|
describe('Per-user authorizations', () => {
|
|
const exchangeForTokens = async (
|
|
scope = 'read write',
|
|
): Promise<{
|
|
accessToken: string;
|
|
refreshToken: string;
|
|
}> => {
|
|
const code = crypto.randomBytes(42).toString('hex');
|
|
const hashedCode = crypto.createHash('sha256').update(code).digest('hex');
|
|
|
|
const tokenId = await insertAppToken(ds, {
|
|
value: hashedCode,
|
|
type: AppTokenType.AuthorizationCode,
|
|
userId: TEST_USER_ID,
|
|
workspaceId: TEST_WORKSPACE_ID,
|
|
expiresAt: new Date(Date.now() + 5 * 60 * 1000),
|
|
context: {
|
|
redirectUri: 'https://example.com/callback',
|
|
clientId: testRegistration.oAuthClientId,
|
|
scope,
|
|
},
|
|
});
|
|
|
|
createdEntityIds.tokens.push(tokenId);
|
|
|
|
const res = await postToken({
|
|
grant_type: 'authorization_code',
|
|
code,
|
|
client_id: testRegistration.oAuthClientId,
|
|
client_secret: testClientSecret,
|
|
redirect_uri: 'https://example.com/callback',
|
|
}).expect(200);
|
|
|
|
return {
|
|
accessToken: res.body.access_token,
|
|
refreshToken: res.body.refresh_token,
|
|
};
|
|
};
|
|
|
|
const findAuthorization = async () => {
|
|
const [authorization] = await ds.query(
|
|
`SELECT "scopes", "lastAuthorizedAt", "revokedAt"
|
|
FROM core."applicationAuthorization"
|
|
WHERE "userId" = $1 AND "applicationId" = $2`,
|
|
[TEST_USER_ID, testApplication.id],
|
|
);
|
|
|
|
return authorization;
|
|
};
|
|
|
|
const deleteAuthorization = () =>
|
|
ds.query(
|
|
`DELETE FROM core."applicationAuthorization"
|
|
WHERE "userId" = $1 AND "applicationId" = $2`,
|
|
[TEST_USER_ID, testApplication.id],
|
|
);
|
|
|
|
const revokeRefreshToken = (refreshToken: string) =>
|
|
request(baseUrl)
|
|
.post('/oauth/revoke')
|
|
.send({
|
|
token: refreshToken,
|
|
client_id: testRegistration.oAuthClientId,
|
|
client_secret: testClientSecret,
|
|
})
|
|
.expect(200);
|
|
|
|
// 'read' alone, not the registration's full scope list, so the assertion
|
|
// fails if the granted scope is ignored in favour of the declared one.
|
|
it('should record the authorization with the scopes the user granted', async () => {
|
|
await exchangeForTokens('read');
|
|
|
|
const authorization = await findAuthorization();
|
|
|
|
expect(authorization).toBeDefined();
|
|
expect(authorization.scopes).toEqual(['read']);
|
|
expect(authorization.revokedAt).toBeNull();
|
|
});
|
|
|
|
it('should stop the refresh token being redeemed once the authorization is revoked', async () => {
|
|
const { refreshToken } = await exchangeForTokens();
|
|
|
|
await revokeRefreshToken(refreshToken);
|
|
|
|
expect((await findAuthorization()).revokedAt).not.toBeNull();
|
|
|
|
const res = await postToken({
|
|
grant_type: 'refresh_token',
|
|
refresh_token: refreshToken,
|
|
client_id: testRegistration.oAuthClientId,
|
|
client_secret: testClientSecret,
|
|
}).expect(400);
|
|
|
|
expect(res.body.error).toBe('invalid_grant');
|
|
});
|
|
|
|
it('should let the user authorize again after revoking', async () => {
|
|
const { refreshToken: revokedRefreshToken } = await exchangeForTokens();
|
|
|
|
await revokeRefreshToken(revokedRefreshToken);
|
|
|
|
const { refreshToken } = await exchangeForTokens();
|
|
|
|
expect((await findAuthorization()).revokedAt).toBeNull();
|
|
|
|
await postToken({
|
|
grant_type: 'refresh_token',
|
|
refresh_token: refreshToken,
|
|
client_id: testRegistration.oAuthClientId,
|
|
client_secret: testClientSecret,
|
|
}).expect(200);
|
|
});
|
|
|
|
// Deleting the row leaves a refresh token in the state every token minted
|
|
// before this table existed is in.
|
|
it('should backfill a refresh token that predates the authorization record without inventing a consent', async () => {
|
|
const { refreshToken } = await exchangeForTokens('read write');
|
|
|
|
await deleteAuthorization();
|
|
|
|
await postToken({
|
|
grant_type: 'refresh_token',
|
|
refresh_token: refreshToken,
|
|
client_id: testRegistration.oAuthClientId,
|
|
client_secret: testClientSecret,
|
|
}).expect(200);
|
|
|
|
const authorization = await findAuthorization();
|
|
|
|
expect(authorization).toBeDefined();
|
|
expect(authorization.scopes).toBeNull();
|
|
expect(authorization.lastAuthorizedAt).toBeNull();
|
|
expect(authorization.revokedAt).toBeNull();
|
|
});
|
|
|
|
it('should keep a refresh token predating the authorization record revoked', async () => {
|
|
const { refreshToken } = await exchangeForTokens();
|
|
|
|
await deleteAuthorization();
|
|
|
|
await revokeRefreshToken(refreshToken);
|
|
|
|
const res = await postToken({
|
|
grant_type: 'refresh_token',
|
|
refresh_token: refreshToken,
|
|
client_id: testRegistration.oAuthClientId,
|
|
client_secret: testClientSecret,
|
|
}).expect(400);
|
|
|
|
expect(res.body.error).toBe('invalid_grant');
|
|
});
|
|
|
|
const LIST_AUTHORIZATIONS_OPERATION = {
|
|
query: gql`
|
|
query CurrentUserApplicationAuthorizations {
|
|
currentUserApplicationAuthorizations {
|
|
id
|
|
applicationId
|
|
applicationName
|
|
scopes
|
|
}
|
|
}
|
|
`,
|
|
};
|
|
|
|
const revokeAuthorizationOperation = (
|
|
applicationAuthorizationId: string,
|
|
) => ({
|
|
query: gql`
|
|
mutation RevokeApplicationAuthorization(
|
|
$applicationAuthorizationId: UUID!
|
|
) {
|
|
revokeApplicationAuthorization(
|
|
applicationAuthorizationId: $applicationAuthorizationId
|
|
)
|
|
}
|
|
`,
|
|
variables: { applicationAuthorizationId },
|
|
});
|
|
|
|
const findListedAuthorization = async (
|
|
token = APPLE_JANE_ADMIN_ACCESS_TOKEN,
|
|
) => {
|
|
const res = await makeMetadataAPIRequest(
|
|
LIST_AUTHORIZATIONS_OPERATION,
|
|
token,
|
|
);
|
|
|
|
expect(res.body.errors).toBeUndefined();
|
|
|
|
return res.body.data.currentUserApplicationAuthorizations.find(
|
|
(authorization: { applicationId: string }) =>
|
|
authorization.applicationId === testApplication.id,
|
|
);
|
|
};
|
|
|
|
const revokeListedAuthorization = (
|
|
applicationAuthorizationId: string,
|
|
token: string,
|
|
) =>
|
|
makeMetadataAPIRequest(
|
|
revokeAuthorizationOperation(applicationAuthorizationId),
|
|
token,
|
|
);
|
|
|
|
it('should list the authorization to the user who granted it', async () => {
|
|
await exchangeForTokens('read');
|
|
|
|
const authorization = await findListedAuthorization();
|
|
|
|
expect(authorization).toBeDefined();
|
|
expect(authorization.applicationName).toBe(testRegistration.name);
|
|
expect(authorization.scopes).toEqual(['read']);
|
|
});
|
|
|
|
it('should stop the refresh token being redeemed when revoked from the list', async () => {
|
|
const { refreshToken } = await exchangeForTokens();
|
|
|
|
const { id } = await findListedAuthorization();
|
|
|
|
const revokeResponse = await revokeListedAuthorization(
|
|
id,
|
|
APPLE_JANE_ADMIN_ACCESS_TOKEN,
|
|
);
|
|
|
|
expect(revokeResponse.body.errors).toBeUndefined();
|
|
expect(revokeResponse.body.data.revokeApplicationAuthorization).toBe(
|
|
true,
|
|
);
|
|
|
|
const res = await postToken({
|
|
grant_type: 'refresh_token',
|
|
refresh_token: refreshToken,
|
|
client_id: testRegistration.oAuthClientId,
|
|
client_secret: testClientSecret,
|
|
}).expect(400);
|
|
|
|
expect(res.body.error).toBe('invalid_grant');
|
|
expect(await findListedAuthorization()).toBeUndefined();
|
|
});
|
|
|
|
it('should report a repeated revocation as a no-op', async () => {
|
|
await exchangeForTokens();
|
|
|
|
const { id } = await findListedAuthorization();
|
|
|
|
const firstRevoke = await revokeListedAuthorization(
|
|
id,
|
|
APPLE_JANE_ADMIN_ACCESS_TOKEN,
|
|
);
|
|
|
|
expect(firstRevoke.body.data.revokeApplicationAuthorization).toBe(true);
|
|
|
|
const secondRevoke = await revokeListedAuthorization(
|
|
id,
|
|
APPLE_JANE_ADMIN_ACCESS_TOKEN,
|
|
);
|
|
|
|
expect(secondRevoke.body.data.revokeApplicationAuthorization).toBe(false);
|
|
});
|
|
|
|
it('should not let another user see or revoke the authorization', async () => {
|
|
await exchangeForTokens();
|
|
|
|
const { id } = await findListedAuthorization();
|
|
|
|
expect(
|
|
await findListedAuthorization(APPLE_JONY_MEMBER_ACCESS_TOKEN),
|
|
).toBeUndefined();
|
|
|
|
const revokeResponse = await revokeListedAuthorization(
|
|
id,
|
|
APPLE_JONY_MEMBER_ACCESS_TOKEN,
|
|
);
|
|
|
|
expect(revokeResponse.body.data.revokeApplicationAuthorization).toBe(
|
|
false,
|
|
);
|
|
expect((await findAuthorization()).revokedAt).toBeNull();
|
|
});
|
|
});
|
|
});
|