Skip to content

Commit 9e52f99

Browse files
Define dynamicStrategy
Implement a passport.js plugin for Dynamic. It is heavily based on the passport-jwt strategy for the MVP
1 parent bcb0239 commit 9e52f99

File tree

6 files changed

+463
-5
lines changed

6 files changed

+463
-5
lines changed

src/index.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1 @@
1-
export class Hello {
2-
public sayHello() {
3-
return 'hello, world!';
4-
}
5-
}
1+
export * from './lib/dynamicStrategy';

src/lib/dynamicStrategy.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { IncomingHttpHeaders } from 'http2';
2+
import { Jwt, JwtPayload, Secret } from 'jsonwebtoken';
3+
import type { StrategyCreated, StrategyCreatedStatic } from 'passport';
4+
// eslint-disable-next-line no-duplicate-imports
5+
import { Strategy } from 'passport';
6+
import { verifyToken } from './verifyToken';
7+
8+
interface StrategyOptions {
9+
publicKey: string;
10+
}
11+
12+
interface Request {
13+
headers: IncomingHttpHeaders;
14+
}
15+
16+
export class DynamicStrategy extends Strategy {
17+
authHeaderRegex = /(\S+)\s+(\S+)/;
18+
19+
_secretOrKeyProvider: (request: Request, rawJwtToken: any, done: any) => void;
20+
verify: (payload: Jwt | JwtPayload | string | undefined, done: any) => void;
21+
22+
constructor(
23+
options: StrategyOptions,
24+
verify: (payload: Jwt | JwtPayload | string | undefined, done: any) => void,
25+
) {
26+
super();
27+
28+
const publicKey = options.publicKey;
29+
30+
if (!publicKey) {
31+
throw new Error(
32+
'You must provide your Dynamic public key for verification.',
33+
);
34+
}
35+
36+
this.name = 'dynamicStrategy';
37+
38+
this.verify = verify;
39+
40+
// Passport expects this to be a callback. Our publicKey is static so
41+
// we wrap it in a simple function that returns it
42+
this._secretOrKeyProvider = (_request, _rawJwtToken, done) => {
43+
done(null, publicKey);
44+
};
45+
}
46+
47+
authenticate(
48+
this: StrategyCreated<this, this & StrategyCreatedStatic>,
49+
req: Request,
50+
_options?: any,
51+
) {
52+
const token = this.jwtFromRequest(req);
53+
54+
if (!token) {
55+
return this.fail('Missing JWT token');
56+
}
57+
58+
this._secretOrKeyProvider(
59+
req,
60+
token,
61+
(_secretOrKeyError: any, secretOrKey: Secret) => {
62+
return verifyToken(token, secretOrKey, (err, payload) => {
63+
if (err) {
64+
return this.fail('Invalid token');
65+
} else {
66+
const verified = (error: any, user: object, info: object) => {
67+
if (error) {
68+
return this.error(error);
69+
} else if (!user) {
70+
return this.fail('User not found');
71+
} else {
72+
return this.success(user, info);
73+
}
74+
};
75+
76+
try {
77+
this.verify(payload, verified);
78+
} catch (ex) {
79+
return this.error(ex);
80+
}
81+
}
82+
});
83+
},
84+
);
85+
}
86+
87+
jwtFromRequest(request: Request) {
88+
let jwtToken = null;
89+
90+
const authHeader = request.headers.authorization;
91+
92+
if (authHeader) {
93+
const bearerSchemeMatches = authHeader.match(this.authHeaderRegex);
94+
jwtToken = bearerSchemeMatches ? bearerSchemeMatches[2] : null;
95+
}
96+
97+
return jwtToken;
98+
}
99+
}

src/lib/verifyToken.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import {
2+
VerifyCallback,
3+
JwtPayload,
4+
Secret,
5+
verify as jwtVerify,
6+
VerifyOptions,
7+
} from 'jsonwebtoken';
8+
// eslint-disable-next-line no-duplicate-imports
9+
import type { Jwt } from 'jsonwebtoken';
10+
11+
export const verifyToken = (
12+
token: string,
13+
key: Secret,
14+
callback: VerifyCallback<string | Jwt | JwtPayload>,
15+
) => {
16+
const verifyOptions: VerifyOptions = {
17+
algorithms: ['RS256'],
18+
complete: false,
19+
};
20+
return jwtVerify(token, key, verifyOptions, callback);
21+
};

src/test/TestServer.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { createServer, Server as HttpServer } from 'http';
2+
3+
import express from 'express';
4+
import passport from 'passport';
5+
import supertest from 'supertest';
6+
7+
afterAll(async () => {
8+
await testServer.close();
9+
});
10+
11+
beforeAll(async () => {
12+
await testServer.init();
13+
});
14+
15+
beforeEach(() => {
16+
jest.clearAllMocks();
17+
});
18+
19+
const isAuthorized = () => (req: any, res: any, next: any) => {
20+
try {
21+
return passport.authenticate('dynamicStrategy', {
22+
session: false,
23+
failWithError: true,
24+
})(req, res, next);
25+
} catch (err) {
26+
return next(err);
27+
}
28+
};
29+
30+
class Server {
31+
private readonly expressApp: express.Application = express();
32+
33+
public constructor() {
34+
this.expressApp.get(
35+
'/user',
36+
isAuthorized(),
37+
function (req, res) {
38+
res.status(200).json(req.user);
39+
},
40+
function (err: any, _req: any, res: any, next: any) {
41+
const status = err.status || 500;
42+
const errorMessage = err.message;
43+
44+
res.status(status);
45+
46+
res.json({
47+
error: errorMessage,
48+
status,
49+
});
50+
51+
next();
52+
},
53+
);
54+
}
55+
56+
public get app(): express.Application {
57+
return this.expressApp;
58+
}
59+
}
60+
61+
export default Server;
62+
63+
class TestServer {
64+
private testApp!: express.Application;
65+
66+
private testServer!: HttpServer;
67+
68+
private Klass: typeof Server;
69+
70+
public constructor(cls = Server) {
71+
this.Klass = cls;
72+
}
73+
74+
public get app(): supertest.SuperTest<supertest.Test> {
75+
return supertest(this.testServer);
76+
}
77+
78+
public async init(): Promise<void> {
79+
this.testApp = new this.Klass().app;
80+
this.testServer = createServer(this.testApp);
81+
}
82+
83+
public async close(): Promise<void> {
84+
await this.testServer.close();
85+
}
86+
}
87+
88+
export const testServer: TestServer = new TestServer();

test/dynamicStrategy.spec.ts

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
import { sign as jwtSign } from 'jsonwebtoken';
2+
// eslint-disable-next-line import/no-extraneous-dependencies
3+
import keypair from 'keypair';
4+
import passport from 'passport';
5+
import { DynamicStrategy } from '../src/lib/dynamicStrategy';
6+
import { testServer } from '../src/test/TestServer';
7+
8+
const keys = keypair();
9+
10+
const user = {
11+
chain: 'ETH',
12+
environmentId: 'fb6dd9d1-09f5-43c3-8a8c-eab6e44c37f9',
13+
lists: [],
14+
userId: '382c1002-e9c1-4fc1-b17c-a887b693b940',
15+
wallet: 'metamask',
16+
walletPublicKey: '0x9249Ecdc1c83e5479289e0bDD9AB96738C51C9Da',
17+
};
18+
19+
const defaultOptions = { publicKey: keys.public };
20+
const passportVerify = async (decodedToken: any, done: any) => {
21+
try {
22+
return done(null, decodedToken.payload);
23+
} catch (err) {
24+
return done(err, false);
25+
}
26+
};
27+
28+
const usePassport = (
29+
options: any = defaultOptions,
30+
verifyCallback = passportVerify,
31+
) => {
32+
passport.use(new DynamicStrategy(options, verifyCallback));
33+
};
34+
35+
describe('DynamicStrategy', () => {
36+
afterEach(() => {
37+
passport.unuse('dynamicStrategy');
38+
});
39+
40+
const generateJWT = (alg = 'RS256') => {
41+
const header = { alg: alg };
42+
const payload = user;
43+
const signedToken = jwtSign(
44+
{
45+
header: header,
46+
payload: payload,
47+
encoding: 'utf8',
48+
},
49+
keys.private,
50+
{ algorithm: 'RS256' },
51+
);
52+
53+
return signedToken;
54+
};
55+
56+
it('throws an error if the strategy does not have a `publicKey`', async () => {
57+
expect(() => {
58+
usePassport({});
59+
}).toThrowError(
60+
'You must provide your Dynamic public key for verification.',
61+
);
62+
});
63+
64+
it('returns 401 if no header is provided', async () => {
65+
usePassport();
66+
67+
const response = await (await testServer).app.get('/user');
68+
69+
expect(response.status).toEqual(401);
70+
expect(response.headers['www-authenticate']).toEqual('Missing JWT token');
71+
});
72+
73+
it('returns 401 if the Authorization header has no value', async () => {
74+
usePassport();
75+
76+
const response = await (await testServer).app
77+
.get('/user')
78+
.set('Authorization', '');
79+
80+
expect(response.status).toEqual(401);
81+
expect(response.headers['www-authenticate']).toEqual('Missing JWT token');
82+
});
83+
84+
it('returns 401 if the Authorization header does not match the Bearer format', async () => {
85+
usePassport();
86+
87+
const response = await (await testServer).app
88+
.get('/user')
89+
.set('Authorization', 'Bearer');
90+
91+
expect(response.status).toEqual(401);
92+
expect(response.headers['www-authenticate']).toEqual('Missing JWT token');
93+
});
94+
95+
it('returns 401 if the JWT is invalid', async () => {
96+
usePassport();
97+
98+
const signedToken = 'Token';
99+
const response = await (await testServer).app
100+
.get('/user')
101+
.set('Authorization', `Bearer ${signedToken}`);
102+
103+
expect(response.status).toEqual(401);
104+
expect(response.headers['www-authenticate']).toEqual('Invalid token');
105+
});
106+
107+
it('returns 500 if the verify callback throws an error', async () => {
108+
const errorVerify = (_payload: any, _done: any) => {
109+
throw new Error('Bad API');
110+
};
111+
112+
usePassport(defaultOptions, errorVerify);
113+
114+
const signedToken = generateJWT();
115+
const response = await (await testServer).app
116+
.get('/user')
117+
.set('Authorization', `Bearer ${signedToken}`);
118+
119+
expect(response.status).toEqual(500);
120+
expect(response.body.error).toEqual('Bad API');
121+
});
122+
123+
it('returns 500 if the the verify callback returns an error', async () => {
124+
const errorVerify = async (_payload: any, done: any) => {
125+
return done(new Error('Unexpected error'), false);
126+
};
127+
128+
usePassport(defaultOptions, errorVerify);
129+
130+
const signedToken = generateJWT();
131+
const response = await (await testServer).app
132+
.get('/user')
133+
.set('Authorization', `Bearer ${signedToken}`);
134+
135+
expect(response.status).toEqual(500);
136+
expect(response.body.error).toEqual('Unexpected error');
137+
});
138+
139+
it('returns 401 if the verify callback does not return a user', async () => {
140+
const verifyNoUser = async (_payload: any, done: any) => {
141+
return done(null, false);
142+
};
143+
144+
usePassport(defaultOptions, verifyNoUser);
145+
146+
const signedToken = generateJWT();
147+
const response = await (await testServer).app
148+
.get('/user')
149+
.set('Authorization', `Bearer ${signedToken}`);
150+
151+
expect(response.status).toEqual(401);
152+
expect(response.headers['www-authenticate']).toEqual('User not found');
153+
});
154+
155+
it('returns 200 if the user can be authenticated', async () => {
156+
usePassport();
157+
158+
const signedToken = generateJWT();
159+
const response = await (await testServer).app
160+
.get('/user')
161+
.set('Authorization', `Bearer ${signedToken}`);
162+
163+
expect(response.status).toEqual(200);
164+
expect(response.body).toEqual(user);
165+
});
166+
});

0 commit comments

Comments
 (0)