crossly.client.auth.service 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.vscode/launch.json +95 -0
- package/.vscode/settings.json +24 -0
- package/.vscode/tasks.json +34 -0
- package/README.md +38 -0
- package/contracts/README.md +28 -0
- package/contracts/package-lock.json +30 -0
- package/contracts/package.json +46 -0
- package/contracts/src/index.ts +53 -0
- package/contracts/tsconfig.json +22 -0
- package/dist/app.js +52 -0
- package/dist/app.js.map +1 -0
- package/dist/config.js +29 -0
- package/dist/config.js.map +1 -0
- package/dist/controllers/authController.js +213 -0
- package/dist/controllers/authController.js.map +1 -0
- package/dist/createApp.js +29 -0
- package/dist/createApp.js.map +1 -0
- package/dist/db/migrate.js +96 -0
- package/dist/db/migrate.js.map +1 -0
- package/dist/managers/authManager.js +79 -0
- package/dist/managers/authManager.js.map +1 -0
- package/dist/managers/types.js +2 -0
- package/dist/managers/types.js.map +1 -0
- package/dist/oidc/googleProvider.js +54 -0
- package/dist/oidc/googleProvider.js.map +1 -0
- package/dist/oidc/notConfiguredOidcProvider.js +14 -0
- package/dist/oidc/notConfiguredOidcProvider.js.map +1 -0
- package/dist/oidc/types.js +2 -0
- package/dist/oidc/types.js.map +1 -0
- package/dist/repository/inMemoryClientRepository.js +60 -0
- package/dist/repository/inMemoryClientRepository.js.map +1 -0
- package/dist/repository/pgClientRepository.js +45 -0
- package/dist/repository/pgClientRepository.js.map +1 -0
- package/dist/repository/types.js +2 -0
- package/dist/repository/types.js.map +1 -0
- package/dist/signer/jwtSigner.js +36 -0
- package/dist/signer/jwtSigner.js.map +1 -0
- package/dist/signer/types.js +2 -0
- package/dist/signer/types.js.map +1 -0
- package/docker-compose.yml +25 -0
- package/migrations/0001_create_clients.sql +16 -0
- package/package.json +50 -0
- package/src/app.ts +61 -0
- package/src/config.ts +51 -0
- package/src/controllers/authController.ts +237 -0
- package/src/createApp.ts +45 -0
- package/src/db/migrate.ts +106 -0
- package/src/managers/authManager.ts +105 -0
- package/src/managers/types.ts +59 -0
- package/src/oidc/googleProvider.ts +72 -0
- package/src/oidc/notConfiguredOidcProvider.ts +16 -0
- package/src/oidc/types.ts +41 -0
- package/src/repository/inMemoryClientRepository.ts +72 -0
- package/src/repository/pgClientRepository.ts +75 -0
- package/src/repository/types.ts +50 -0
- package/src/signer/jwtSigner.ts +49 -0
- package/src/signer/types.ts +14 -0
- package/tests/integration/auth.api.test.ts +212 -0
- package/tests/integration/fakeOidcProvider.ts +32 -0
- package/tests/unit/authManager.test.ts +87 -0
- package/tests/unit/clientRepository.test.ts +115 -0
- package/tests/unit/jwtSigner.test.ts +42 -0
- package/tests/unit/resolveLogin.test.ts +86 -0
- package/tsconfig.build.json +11 -0
- package/tsconfig.json +24 -0
- package/tsconfig.test.json +25 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { SignJWT, jwtVerify } from 'jose';
|
|
2
|
+
import type { AccessTokenClaims } from '@textyly/crossly-client-auth-contracts';
|
|
3
|
+
import type { IJwtSigner } from './types.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* HS256 JWT signer backed by `jose`.
|
|
7
|
+
*
|
|
8
|
+
* "Extremely simple" by design: a single shared secret both signs and verifies
|
|
9
|
+
* tokens. When real identity providers are introduced, swap this for an
|
|
10
|
+
* RS256/JWKS implementation of {@link IJwtSigner} — nothing else in the service
|
|
11
|
+
* (or in consumers) changes, because the token shape stays the same.
|
|
12
|
+
*/
|
|
13
|
+
export class JwtSigner implements IJwtSigner {
|
|
14
|
+
private static readonly algorithm: string = 'HS256';
|
|
15
|
+
private readonly key: Uint8Array;
|
|
16
|
+
|
|
17
|
+
public constructor(secret: string) {
|
|
18
|
+
this.key = new TextEncoder().encode(secret);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
public async sign(
|
|
22
|
+
sub: string,
|
|
23
|
+
guest: boolean,
|
|
24
|
+
ttlSeconds: number,
|
|
25
|
+
): Promise<{ token: string; expiresAt: number }> {
|
|
26
|
+
const issuedAt = Math.floor(Date.now() / 1000);
|
|
27
|
+
const expiresAt = issuedAt + ttlSeconds;
|
|
28
|
+
|
|
29
|
+
const token = await new SignJWT({ guest })
|
|
30
|
+
.setProtectedHeader({ alg: JwtSigner.algorithm })
|
|
31
|
+
.setSubject(sub)
|
|
32
|
+
.setIssuedAt(issuedAt)
|
|
33
|
+
.setExpirationTime(expiresAt)
|
|
34
|
+
.sign(this.key);
|
|
35
|
+
|
|
36
|
+
return { token, expiresAt };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
public async verify(token: string): Promise<AccessTokenClaims> {
|
|
40
|
+
const { payload } = await jwtVerify(token, this.key);
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
sub: payload.sub as string,
|
|
44
|
+
guest: payload.guest as boolean,
|
|
45
|
+
iat: payload.iat as number,
|
|
46
|
+
exp: payload.exp as number,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { AccessTokenClaims } from '@textyly/crossly-client-auth-contracts';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Token signing/verification boundary — the lowest layer of the service
|
|
5
|
+
* (the auth analogue of a repository). Implementations wrap a concrete JWT
|
|
6
|
+
* library + key; the rest of the service depends only on this interface.
|
|
7
|
+
*/
|
|
8
|
+
export interface IJwtSigner {
|
|
9
|
+
/** Sign an access token for `sub`, valid for `ttlSeconds`. Returns the token and its expiry. */
|
|
10
|
+
sign(sub: string, guest: boolean, ttlSeconds: number): Promise<{ token: string; expiresAt: number }>;
|
|
11
|
+
|
|
12
|
+
/** Verify a token and return its claims, or throw if it is invalid or expired. */
|
|
13
|
+
verify(token: string): Promise<AccessTokenClaims>;
|
|
14
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { expect } from 'chai';
|
|
2
|
+
import request from 'supertest';
|
|
3
|
+
import type { MeResponse, SessionResponse } from '@textyly/crossly-client-auth-contracts';
|
|
4
|
+
import { createApp } from '../../src/createApp.js';
|
|
5
|
+
import { JwtSigner } from '../../src/signer/jwtSigner.js';
|
|
6
|
+
import { InMemoryClientRepository } from '../../src/repository/inMemoryClientRepository.js';
|
|
7
|
+
import { NotConfiguredOidcProvider } from '../../src/oidc/notConfiguredOidcProvider.js';
|
|
8
|
+
import { FakeOidcProvider } from './fakeOidcProvider.js';
|
|
9
|
+
import type { AuthConfig } from '../../src/config.js';
|
|
10
|
+
|
|
11
|
+
// Integration tests drive the full HTTP stack (controller -> manager -> signer/repo)
|
|
12
|
+
// through supertest, using an in-memory repo + a fake OIDC provider so no database
|
|
13
|
+
// or live Google is required. `request.agent(app)` persists cookies between
|
|
14
|
+
// requests, which is how we exercise the httpOnly session/OAuth cookies end-to-end.
|
|
15
|
+
describe('auth API (integration, cookie model)', () => {
|
|
16
|
+
const signer = new JwtSigner('test-secret');
|
|
17
|
+
const config: AuthConfig = {
|
|
18
|
+
cookieSecret: 'test-cookie-secret',
|
|
19
|
+
uiRedirectUrl: 'http://localhost:5000',
|
|
20
|
+
corsOrigin: 'http://localhost:5000',
|
|
21
|
+
secureCookies: false,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
type Agent = ReturnType<typeof request.agent>;
|
|
25
|
+
|
|
26
|
+
let clients: InMemoryClientRepository;
|
|
27
|
+
let oidc: FakeOidcProvider;
|
|
28
|
+
let app: ReturnType<typeof createApp>;
|
|
29
|
+
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
clients = new InMemoryClientRepository();
|
|
32
|
+
oidc = new FakeOidcProvider({
|
|
33
|
+
provider: 'google',
|
|
34
|
+
subject: 'google-test',
|
|
35
|
+
email: 'test@example.com',
|
|
36
|
+
});
|
|
37
|
+
app = createApp({ signer, clients, oidc, config });
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// Run the OAuth dance on an agent: /login -> read state from the redirect -> /callback.
|
|
41
|
+
async function login(agent: Agent): Promise<request.Response> {
|
|
42
|
+
const start = await agent.get('/api/v1/auth/login');
|
|
43
|
+
const state = new URL(start.headers.location).searchParams.get('state') ?? '';
|
|
44
|
+
return agent.get(`/api/v1/auth/callback?code=fake-code&state=${encodeURIComponent(state)}`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function sessionCookie(res: request.Response): string | undefined {
|
|
48
|
+
const set = res.headers['set-cookie'] as unknown as string[] | undefined;
|
|
49
|
+
return set?.find((cookie) => cookie.startsWith('crossly_session='));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
it('GET /health returns ok', async () => {
|
|
53
|
+
const response = await request(app).get('/health');
|
|
54
|
+
expect(response.status).to.equal(200);
|
|
55
|
+
expect(response.body).to.deep.equal({ status: 'ok' });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('POST /api/v1/auth/guest sets an httpOnly session cookie and returns a guest summary', async () => {
|
|
59
|
+
const agent = request.agent(app);
|
|
60
|
+
const response = await agent.post('/api/v1/auth/guest');
|
|
61
|
+
|
|
62
|
+
expect(response.status).to.equal(201);
|
|
63
|
+
const body = response.body as SessionResponse;
|
|
64
|
+
expect(body.clientId).to.be.a('string').with.length.greaterThan(0);
|
|
65
|
+
expect(body.guest).to.equal(true);
|
|
66
|
+
|
|
67
|
+
const cookie = sessionCookie(response);
|
|
68
|
+
expect(cookie, 'session cookie set').to.be.a('string');
|
|
69
|
+
expect(cookie).to.contain('HttpOnly');
|
|
70
|
+
|
|
71
|
+
// The cookie is a valid session: /validate accepts it and reports the same id.
|
|
72
|
+
const validated = await agent.get('/api/v1/auth/validate');
|
|
73
|
+
expect(validated.status).to.equal(200);
|
|
74
|
+
expect(validated.headers['x-client-id']).to.equal(body.clientId);
|
|
75
|
+
expect(validated.headers['x-guest']).to.equal('true');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('issues a different clientId for each guest', async () => {
|
|
79
|
+
const first = await request.agent(app).post('/api/v1/auth/guest');
|
|
80
|
+
const second = await request.agent(app).post('/api/v1/auth/guest');
|
|
81
|
+
expect(first.body.clientId).to.not.equal(second.body.clientId);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('POST /api/v1/auth/guest is idempotent — a second call returns the same guest (200)', async () => {
|
|
85
|
+
const agent = request.agent(app);
|
|
86
|
+
const first = await agent.post('/api/v1/auth/guest');
|
|
87
|
+
expect(first.status).to.equal(201);
|
|
88
|
+
|
|
89
|
+
const second = await agent.post('/api/v1/auth/guest');
|
|
90
|
+
expect(second.status).to.equal(200);
|
|
91
|
+
expect((second.body as SessionResponse).clientId).to.equal(first.body.clientId);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('POST /api/v1/auth/guest does not downgrade a logged-in user', async () => {
|
|
95
|
+
const agent = request.agent(app);
|
|
96
|
+
await agent.post('/api/v1/auth/guest');
|
|
97
|
+
await login(agent);
|
|
98
|
+
const me = (await agent.get('/api/v1/auth/me')).body as MeResponse;
|
|
99
|
+
expect(me.guest).to.equal(false);
|
|
100
|
+
|
|
101
|
+
const response = await agent.post('/api/v1/auth/guest');
|
|
102
|
+
expect(response.status).to.equal(200);
|
|
103
|
+
const body = response.body as SessionResponse;
|
|
104
|
+
expect(body.clientId).to.equal(me.clientId); // same account, not a new guest
|
|
105
|
+
expect(body.guest).to.equal(false); // still authenticated
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('POST /api/v1/auth/refresh rolls the session cookie forward, keeping the clientId', async () => {
|
|
109
|
+
const agent = request.agent(app);
|
|
110
|
+
const guest = await agent.post('/api/v1/auth/guest');
|
|
111
|
+
|
|
112
|
+
const refreshed = await agent.post('/api/v1/auth/refresh');
|
|
113
|
+
expect(refreshed.status).to.equal(200);
|
|
114
|
+
const body = refreshed.body as SessionResponse;
|
|
115
|
+
expect(body.clientId).to.equal(guest.body.clientId);
|
|
116
|
+
expect(body.guest).to.equal(true);
|
|
117
|
+
expect(sessionCookie(refreshed)).to.be.a('string');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('rejects refresh / validate / me with no session cookie (401)', async () => {
|
|
121
|
+
expect((await request(app).post('/api/v1/auth/refresh')).status).to.equal(401);
|
|
122
|
+
expect((await request(app).get('/api/v1/auth/validate')).status).to.equal(401);
|
|
123
|
+
expect((await request(app).get('/api/v1/auth/me')).status).to.equal(401);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('GET /api/v1/auth/login redirects to the provider with state and sets the oauth cookie', async () => {
|
|
127
|
+
const response = await request(app).get('/api/v1/auth/login');
|
|
128
|
+
|
|
129
|
+
expect(response.status).to.equal(302);
|
|
130
|
+
const location = new URL(response.headers.location);
|
|
131
|
+
expect(location.host).to.equal('fake-idp.test');
|
|
132
|
+
expect(location.searchParams.get('state')).to.be.a('string').with.length.greaterThan(0);
|
|
133
|
+
|
|
134
|
+
const set = response.headers['set-cookie'] as unknown as string[] | undefined;
|
|
135
|
+
expect(set?.some((cookie) => cookie.startsWith('crossly_oauth='))).to.equal(true);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('GET /api/v1/auth/callback completes login, sets an authenticated session, and /me reflects it', async () => {
|
|
139
|
+
const agent = request.agent(app);
|
|
140
|
+
const callback = await login(agent);
|
|
141
|
+
|
|
142
|
+
expect(callback.status).to.equal(302);
|
|
143
|
+
expect(callback.headers.location).to.equal(config.uiRedirectUrl);
|
|
144
|
+
|
|
145
|
+
const me = (await agent.get('/api/v1/auth/me')).body as MeResponse;
|
|
146
|
+
expect(me.guest).to.equal(false);
|
|
147
|
+
expect(me.clientId).to.be.a('string').with.length.greaterThan(0);
|
|
148
|
+
expect(me.email).to.equal('test@example.com');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('rejects a callback whose state does not match the cookie (CSRF) with 400', async () => {
|
|
152
|
+
const agent = request.agent(app);
|
|
153
|
+
await agent.get('/api/v1/auth/login'); // sets the oauth cookie with the real state
|
|
154
|
+
|
|
155
|
+
const response = await agent.get('/api/v1/auth/callback?code=fake-code&state=tampered');
|
|
156
|
+
expect(response.status).to.equal(400);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('rejects a callback with no oauth cookie (400)', async () => {
|
|
160
|
+
const response = await request(app).get('/api/v1/auth/callback?code=fake-code&state=whatever');
|
|
161
|
+
expect(response.status).to.equal(400);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('promotes the guest in place: after login /me keeps the guest clientId', async () => {
|
|
165
|
+
const agent = request.agent(app);
|
|
166
|
+
const guest = await agent.post('/api/v1/auth/guest');
|
|
167
|
+
const guestClientId = (guest.body as SessionResponse).clientId;
|
|
168
|
+
|
|
169
|
+
await login(agent);
|
|
170
|
+
|
|
171
|
+
const me = (await agent.get('/api/v1/auth/me')).body as MeResponse;
|
|
172
|
+
expect(me.clientId).to.equal(guestClientId);
|
|
173
|
+
expect(me.guest).to.equal(false);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('a returning login on a different device resolves to the same client', async () => {
|
|
177
|
+
// Device A: guest -> login (promotes), capture the account id.
|
|
178
|
+
const deviceA = request.agent(app);
|
|
179
|
+
await deviceA.post('/api/v1/auth/guest');
|
|
180
|
+
await login(deviceA);
|
|
181
|
+
const idA = ((await deviceA.get('/api/v1/auth/me')).body as MeResponse).clientId;
|
|
182
|
+
|
|
183
|
+
// Device B: fresh agent, no guest -> login with the same identity.
|
|
184
|
+
const deviceB = request.agent(app);
|
|
185
|
+
await login(deviceB);
|
|
186
|
+
const idB = ((await deviceB.get('/api/v1/auth/me')).body as MeResponse).clientId;
|
|
187
|
+
|
|
188
|
+
expect(idB).to.equal(idA);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('POST /api/v1/auth/logout clears the session; /me then returns 401', async () => {
|
|
192
|
+
const agent = request.agent(app);
|
|
193
|
+
await agent.post('/api/v1/auth/guest');
|
|
194
|
+
|
|
195
|
+
const loggedOut = await agent.post('/api/v1/auth/logout');
|
|
196
|
+
expect(loggedOut.status).to.equal(204);
|
|
197
|
+
|
|
198
|
+
expect((await agent.get('/api/v1/auth/me')).status).to.equal(401);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('GET /api/v1/auth/login returns 503 when no provider is configured', async () => {
|
|
202
|
+
const disabled = createApp({
|
|
203
|
+
signer,
|
|
204
|
+
clients: new InMemoryClientRepository(),
|
|
205
|
+
oidc: new NotConfiguredOidcProvider(),
|
|
206
|
+
config,
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
const response = await request(disabled).get('/api/v1/auth/login');
|
|
210
|
+
expect(response.status).to.equal(503);
|
|
211
|
+
});
|
|
212
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { IOidcProvider, OidcIdentity } from '../../src/oidc/types.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Test double for {@link IOidcProvider}. `authorizeUrl` returns a fake provider
|
|
5
|
+
* URL that echoes the `state` (so tests can read it back from the redirect), and
|
|
6
|
+
* `exchangeCode` returns a preset identity — letting the login/callback flow be
|
|
7
|
+
* driven end-to-end with no live provider.
|
|
8
|
+
*/
|
|
9
|
+
export class FakeOidcProvider implements IOidcProvider {
|
|
10
|
+
public lastState?: string;
|
|
11
|
+
public lastCodeVerifier?: string;
|
|
12
|
+
|
|
13
|
+
public constructor(private identity: OidcIdentity) {}
|
|
14
|
+
|
|
15
|
+
/** Change the identity the next `exchangeCode` returns. */
|
|
16
|
+
public setIdentity(identity: OidcIdentity): void {
|
|
17
|
+
this.identity = identity;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
public async authorizeUrl(state: string, codeVerifier: string): Promise<string> {
|
|
21
|
+
this.lastState = state;
|
|
22
|
+
this.lastCodeVerifier = codeVerifier;
|
|
23
|
+
return `https://fake-idp.test/authorize?state=${encodeURIComponent(state)}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
public async exchangeCode(
|
|
27
|
+
_callbackParams: URLSearchParams,
|
|
28
|
+
_codeVerifier: string,
|
|
29
|
+
): Promise<OidcIdentity> {
|
|
30
|
+
return this.identity;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { expect } from 'chai';
|
|
2
|
+
import { AuthManager } from '../../src/managers/authManager.js';
|
|
3
|
+
import { JwtSigner } from '../../src/signer/jwtSigner.js';
|
|
4
|
+
import { InMemoryClientRepository } from '../../src/repository/inMemoryClientRepository.js';
|
|
5
|
+
|
|
6
|
+
describe('AuthManager', () => {
|
|
7
|
+
const signer = new JwtSigner('test-secret');
|
|
8
|
+
let manager: AuthManager;
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
manager = new AuthManager(signer, new InMemoryClientRepository());
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('creates a guest session with a token, clientId and expiry', async () => {
|
|
15
|
+
const session = await manager.createGuestSession();
|
|
16
|
+
|
|
17
|
+
expect(session.token).to.be.a('string').with.length.greaterThan(0);
|
|
18
|
+
expect(session.clientId).to.be.a('string').with.length.greaterThan(0);
|
|
19
|
+
expect(session.expiresAt).to.be.a('number');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('issues a token whose subject equals the clientId and is marked guest', async () => {
|
|
23
|
+
const session = await manager.createGuestSession();
|
|
24
|
+
|
|
25
|
+
const claims = await signer.verify(session.token);
|
|
26
|
+
|
|
27
|
+
expect(claims.sub).to.equal(session.clientId);
|
|
28
|
+
expect(claims.guest).to.equal(true);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('issues a session valid for ~1 year', async () => {
|
|
32
|
+
const before = Math.floor(Date.now() / 1000);
|
|
33
|
+
const session = await manager.createGuestSession();
|
|
34
|
+
|
|
35
|
+
const oneYear = 60 * 60 * 24 * 365;
|
|
36
|
+
expect(session.expiresAt).to.be.closeTo(before + oneYear, 5);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('gives a different clientId on each call', async () => {
|
|
40
|
+
const first = await manager.createGuestSession();
|
|
41
|
+
const second = await manager.createGuestSession();
|
|
42
|
+
|
|
43
|
+
expect(first.clientId).to.not.equal(second.clientId);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('refreshes a session, preserving the clientId and guest flag', async () => {
|
|
47
|
+
const original = await manager.createGuestSession();
|
|
48
|
+
|
|
49
|
+
const refreshed = await manager.refreshSession(original.token);
|
|
50
|
+
|
|
51
|
+
expect(refreshed.clientId).to.equal(original.clientId);
|
|
52
|
+
const claims = await signer.verify(refreshed.token);
|
|
53
|
+
expect(claims.sub).to.equal(original.clientId);
|
|
54
|
+
expect(claims.guest).to.equal(true);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('rejects refreshing an invalid token', async () => {
|
|
58
|
+
let threw = false;
|
|
59
|
+
try {
|
|
60
|
+
await manager.refreshSession('not-a-jwt');
|
|
61
|
+
} catch {
|
|
62
|
+
threw = true;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
expect(threw).to.equal(true);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('validates a token and returns its claims', async () => {
|
|
69
|
+
const session = await manager.createGuestSession();
|
|
70
|
+
|
|
71
|
+
const claims = await manager.validate(session.token);
|
|
72
|
+
|
|
73
|
+
expect(claims.sub).to.equal(session.clientId);
|
|
74
|
+
expect(claims.guest).to.equal(true);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('rejects validating an invalid token', async () => {
|
|
78
|
+
let threw = false;
|
|
79
|
+
try {
|
|
80
|
+
await manager.validate('not-a-jwt');
|
|
81
|
+
} catch {
|
|
82
|
+
threw = true;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
expect(threw).to.equal(true);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { expect } from 'chai';
|
|
2
|
+
import { InMemoryClientRepository } from '../../src/repository/inMemoryClientRepository.js';
|
|
3
|
+
import type { NewClient } from '../../src/repository/types.js';
|
|
4
|
+
|
|
5
|
+
describe('InMemoryClientRepository', () => {
|
|
6
|
+
let repository: InMemoryClientRepository;
|
|
7
|
+
|
|
8
|
+
const sample: NewClient = {
|
|
9
|
+
clientId: 'client-1',
|
|
10
|
+
provider: 'google',
|
|
11
|
+
providerSubject: 'google-123',
|
|
12
|
+
email: 'user@example.com',
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
repository = new InMemoryClientRepository();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('creates a client and finds it by provider identity', async () => {
|
|
20
|
+
const created = await repository.create(sample);
|
|
21
|
+
|
|
22
|
+
expect(created.clientId).to.equal('client-1');
|
|
23
|
+
expect(created.createdAt).to.be.instanceOf(Date);
|
|
24
|
+
expect(created.lastLoginAt).to.equal(undefined);
|
|
25
|
+
|
|
26
|
+
const found = await repository.findByProvider('google', 'google-123');
|
|
27
|
+
expect(found?.clientId).to.equal('client-1');
|
|
28
|
+
expect(found?.email).to.equal('user@example.com');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('finds a client by its clientId', async () => {
|
|
32
|
+
await repository.create(sample);
|
|
33
|
+
|
|
34
|
+
const found = await repository.findById('client-1');
|
|
35
|
+
expect(found?.providerSubject).to.equal('google-123');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('returns undefined for an unknown identity or id', async () => {
|
|
39
|
+
expect(await repository.findByProvider('google', 'nope')).to.equal(undefined);
|
|
40
|
+
expect(await repository.findById('nope')).to.equal(undefined);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('rejects a duplicate (provider, subject)', async () => {
|
|
44
|
+
await repository.create(sample);
|
|
45
|
+
|
|
46
|
+
let threw = false;
|
|
47
|
+
try {
|
|
48
|
+
await repository.create({
|
|
49
|
+
clientId: 'client-2',
|
|
50
|
+
provider: 'google',
|
|
51
|
+
providerSubject: 'google-123',
|
|
52
|
+
});
|
|
53
|
+
} catch {
|
|
54
|
+
threw = true;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
expect(threw).to.equal(true);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('rejects a duplicate clientId', async () => {
|
|
61
|
+
await repository.create(sample);
|
|
62
|
+
|
|
63
|
+
let threw = false;
|
|
64
|
+
try {
|
|
65
|
+
await repository.create({
|
|
66
|
+
clientId: 'client-1',
|
|
67
|
+
provider: 'github',
|
|
68
|
+
providerSubject: 'github-9',
|
|
69
|
+
});
|
|
70
|
+
} catch {
|
|
71
|
+
threw = true;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
expect(threw).to.equal(true);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('treats the same subject under different providers as different clients', async () => {
|
|
78
|
+
await repository.create(sample);
|
|
79
|
+
|
|
80
|
+
const other = await repository.create({
|
|
81
|
+
clientId: 'client-2',
|
|
82
|
+
provider: 'github',
|
|
83
|
+
providerSubject: 'google-123',
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
expect(other.clientId).to.equal('client-2');
|
|
87
|
+
expect((await repository.findByProvider('github', 'google-123'))?.clientId).to.equal(
|
|
88
|
+
'client-2',
|
|
89
|
+
);
|
|
90
|
+
expect((await repository.findByProvider('google', 'google-123'))?.clientId).to.equal(
|
|
91
|
+
'client-1',
|
|
92
|
+
);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('sets lastLoginAt on touchLastLogin', async () => {
|
|
96
|
+
await repository.create(sample);
|
|
97
|
+
expect((await repository.findById('client-1'))?.lastLoginAt).to.equal(undefined);
|
|
98
|
+
|
|
99
|
+
await repository.touchLastLogin('client-1');
|
|
100
|
+
expect((await repository.findById('client-1'))?.lastLoginAt).to.be.instanceOf(Date);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('touchLastLogin is a no-op for an unknown client', async () => {
|
|
104
|
+
await repository.touchLastLogin('nope');
|
|
105
|
+
expect(await repository.findById('nope')).to.equal(undefined);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('does not let callers mutate stored state by reference', async () => {
|
|
109
|
+
const created = await repository.create(sample);
|
|
110
|
+
created.email = 'tampered@example.com';
|
|
111
|
+
|
|
112
|
+
const found = await repository.findById('client-1');
|
|
113
|
+
expect(found?.email).to.equal('user@example.com');
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { expect } from 'chai';
|
|
2
|
+
import { JwtSigner } from '../../src/signer/jwtSigner.js';
|
|
3
|
+
|
|
4
|
+
describe('JwtSigner', () => {
|
|
5
|
+
const signer = new JwtSigner('test-secret');
|
|
6
|
+
|
|
7
|
+
it('signs a token and verifies its claims', async () => {
|
|
8
|
+
const { token, expiresAt } = await signer.sign('client-1', true, 3600);
|
|
9
|
+
|
|
10
|
+
const claims = await signer.verify(token);
|
|
11
|
+
|
|
12
|
+
expect(claims.sub).to.equal('client-1');
|
|
13
|
+
expect(claims.guest).to.equal(true);
|
|
14
|
+
expect(claims.exp).to.equal(expiresAt);
|
|
15
|
+
expect(claims.iat).to.be.a('number');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('rejects a token signed with a different secret', async () => {
|
|
19
|
+
const { token } = await signer.sign('client-1', true, 3600);
|
|
20
|
+
const other = new JwtSigner('different-secret');
|
|
21
|
+
|
|
22
|
+
let threw = false;
|
|
23
|
+
try {
|
|
24
|
+
await other.verify(token);
|
|
25
|
+
} catch {
|
|
26
|
+
threw = true;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
expect(threw).to.equal(true);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('rejects a malformed token', async () => {
|
|
33
|
+
let threw = false;
|
|
34
|
+
try {
|
|
35
|
+
await signer.verify('not-a-jwt');
|
|
36
|
+
} catch {
|
|
37
|
+
threw = true;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
expect(threw).to.equal(true);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { expect } from 'chai';
|
|
2
|
+
import { AuthManager } from '../../src/managers/authManager.js';
|
|
3
|
+
import { JwtSigner } from '../../src/signer/jwtSigner.js';
|
|
4
|
+
import { InMemoryClientRepository } from '../../src/repository/inMemoryClientRepository.js';
|
|
5
|
+
import type { OidcIdentity } from '../../src/oidc/types.js';
|
|
6
|
+
|
|
7
|
+
describe('AuthManager.resolveLogin', () => {
|
|
8
|
+
const signer = new JwtSigner('test-secret');
|
|
9
|
+
let clients: InMemoryClientRepository;
|
|
10
|
+
let manager: AuthManager;
|
|
11
|
+
|
|
12
|
+
const googleAlice: OidcIdentity = {
|
|
13
|
+
provider: 'google',
|
|
14
|
+
subject: 'google-alice',
|
|
15
|
+
email: 'alice@example.com',
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
clients = new InMemoryClientRepository();
|
|
20
|
+
manager = new AuthManager(signer, clients);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('creates a brand-new client when there is no guest and no existing identity', async () => {
|
|
24
|
+
const result = await manager.resolveLogin(googleAlice);
|
|
25
|
+
|
|
26
|
+
expect(result.created).to.equal(true);
|
|
27
|
+
expect(result.promoted).to.equal(false);
|
|
28
|
+
expect(result.clientId).to.be.a('string').with.length.greaterThan(0);
|
|
29
|
+
|
|
30
|
+
const stored = await clients.findByProvider('google', 'google-alice');
|
|
31
|
+
expect(stored?.clientId).to.equal(result.clientId);
|
|
32
|
+
expect(stored?.email).to.equal('alice@example.com');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('promotes the guest in place — the account adopts the guest clientId', async () => {
|
|
36
|
+
const guestId = 'guest-123';
|
|
37
|
+
|
|
38
|
+
const result = await manager.resolveLogin(googleAlice, guestId);
|
|
39
|
+
|
|
40
|
+
expect(result.promoted).to.equal(true);
|
|
41
|
+
expect(result.created).to.equal(true);
|
|
42
|
+
expect(result.clientId).to.equal(guestId);
|
|
43
|
+
|
|
44
|
+
const stored = await clients.findByProvider('google', 'google-alice');
|
|
45
|
+
expect(stored?.clientId).to.equal(guestId);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('returns the existing client on a returning login (no new row, no promotion)', async () => {
|
|
49
|
+
const first = await manager.resolveLogin(googleAlice, 'guest-abc'); // promote
|
|
50
|
+
const second = await manager.resolveLogin(googleAlice, 'guest-xyz'); // returning, fresh guest
|
|
51
|
+
|
|
52
|
+
expect(second.clientId).to.equal(first.clientId); // same account, not the new guest id
|
|
53
|
+
expect(second.created).to.equal(false);
|
|
54
|
+
expect(second.promoted).to.equal(false);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('updates last_login on a returning login', async () => {
|
|
58
|
+
const { clientId } = await manager.resolveLogin(googleAlice);
|
|
59
|
+
expect((await clients.findById(clientId))?.lastLoginAt).to.equal(undefined);
|
|
60
|
+
|
|
61
|
+
await manager.resolveLogin(googleAlice);
|
|
62
|
+
expect((await clients.findById(clientId))?.lastLoginAt).to.be.instanceOf(Date);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('treats a different provider with the same subject as a different client', async () => {
|
|
66
|
+
const google = await manager.resolveLogin({ provider: 'google', subject: 'shared-sub' });
|
|
67
|
+
const github = await manager.resolveLogin({ provider: 'github', subject: 'shared-sub' });
|
|
68
|
+
|
|
69
|
+
expect(github.clientId).to.not.equal(google.clientId);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('never matches on email (same email, different identity → different client)', async () => {
|
|
73
|
+
const a = await manager.resolveLogin({
|
|
74
|
+
provider: 'google',
|
|
75
|
+
subject: 's1',
|
|
76
|
+
email: 'same@example.com',
|
|
77
|
+
});
|
|
78
|
+
const b = await manager.resolveLogin({
|
|
79
|
+
provider: 'github',
|
|
80
|
+
subject: 's2',
|
|
81
|
+
email: 'same@example.com',
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
expect(b.clientId).to.not.equal(a.clientId);
|
|
85
|
+
});
|
|
86
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2020",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"paths": {
|
|
7
|
+
"@textyly/crossly-client-auth-contracts": ["./contracts/dist/index.d.ts"]
|
|
8
|
+
},
|
|
9
|
+
"strict": true,
|
|
10
|
+
"sourceMap": true,
|
|
11
|
+
"inlineSources": true,
|
|
12
|
+
"esModuleInterop": true,
|
|
13
|
+
"noEmit": true
|
|
14
|
+
},
|
|
15
|
+
"include": [
|
|
16
|
+
"src/**/*",
|
|
17
|
+
"tests/**/*"
|
|
18
|
+
],
|
|
19
|
+
"exclude": [
|
|
20
|
+
"node_modules",
|
|
21
|
+
"dist",
|
|
22
|
+
"dist-tests"
|
|
23
|
+
]
|
|
24
|
+
}
|