Skip to content

Commit 1ed8064

Browse files
committed
id token auth for LINE provider
1 parent 31f70a3 commit 1ed8064

2 files changed

Lines changed: 529 additions & 6 deletions

File tree

spec/Adapters/Auth/line.spec.js

Lines changed: 333 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,171 @@
11
const LineAdapter = require('../../../lib/Adapters/Auth/line').default;
2+
const jwt = require('jsonwebtoken');
3+
const authUtils = require('../../../lib/Adapters/Auth/utils');
4+
25
describe('LineAdapter', function () {
36
let adapter;
47

8+
const validOptions = {
9+
clientId: 'validClientId',
10+
clientSecret: 'validClientSecret',
11+
};
12+
13+
// Stub LINE JWKS lookup and JWT verification so auth-flow tests stay deterministic
14+
// and do not depend on live LINE signing keys.
15+
function mockEs256IdTokenVerification(claims = {}) {
16+
const jwtClaims = {
17+
iss: 'https://access.line.me',
18+
aud: 'validClientId',
19+
exp: Date.now() + 1000,
20+
sub: 'mockUserId',
21+
...claims,
22+
};
23+
const getHeaderSpy = jasmine.isSpy(authUtils.getHeaderFromToken)
24+
? authUtils.getHeaderFromToken
25+
: spyOn(authUtils, 'getHeaderFromToken');
26+
const getSigningKeySpy = jasmine.isSpy(authUtils.getSigningKey)
27+
? authUtils.getSigningKey
28+
: spyOn(authUtils, 'getSigningKey');
29+
const verifySpy = jasmine.isSpy(jwt.verify) ? jwt.verify : spyOn(jwt, 'verify');
30+
getHeaderSpy.and.returnValue({ kid: '123', alg: 'ES256' });
31+
getSigningKeySpy.and.resolveTo({ publicKey: 'line_public_key' });
32+
verifySpy.and.returnValue(jwtClaims);
33+
return jwtClaims;
34+
}
35+
536
beforeEach(function () {
637
adapter = new LineAdapter.constructor();
738
adapter.clientId = 'validClientId';
839
adapter.clientSecret = 'validClientSecret';
940
});
1041

42+
describe('validateOptions', function () {
43+
it('should allow secure id token validation with clientId only', function () {
44+
expect(() => adapter.validateOptions({ clientId: 'validClientId' })).not.toThrow();
45+
expect(adapter.clientId).toBe('validClientId');
46+
expect(adapter.clientSecret).toBeUndefined();
47+
});
48+
49+
it('should allow insecure-only configuration when explicitly enabled', function () {
50+
expect(() => adapter.validateOptions({ enableInsecureAuth: true })).not.toThrow();
51+
expect(adapter.enableInsecureAuth).toBeTrue();
52+
});
53+
54+
it('should require clientId when secure auth is configured', function () {
55+
expect(() => adapter.validateOptions({ clientSecret: 'validClientSecret' })).toThrowError(
56+
'Line clientId is required.'
57+
);
58+
});
59+
});
60+
61+
describe('verifyIdToken', function () {
62+
beforeEach(function () {
63+
adapter.validateOptions(validOptions);
64+
});
65+
66+
it('should throw an error if id_token is missing', async function () {
67+
await expectAsync(adapter.verifyIdToken({})).toBeRejectedWithError(
68+
'id token is invalid for this user.'
69+
);
70+
});
71+
72+
it('should not decode an invalid id_token', async function () {
73+
await expectAsync(adapter.verifyIdToken({ id_token: 'the_token' })).toBeRejectedWithError(
74+
'provided token does not decode as JWT'
75+
);
76+
});
77+
78+
it('should throw an error if public key used to encode token is not available', async function () {
79+
spyOn(authUtils, 'getHeaderFromToken').and.returnValue({ kid: '789', alg: 'ES256' });
80+
81+
await expectAsync(adapter.verifyIdToken({ id_token: 'the_token' })).toBeRejectedWithError(
82+
'Unable to find matching key for Key ID: 789'
83+
);
84+
});
85+
86+
it('should guard and pass only a valid supported algorithm to jwt.verify', async function () {
87+
const fakeClaim = mockEs256IdTokenVerification();
88+
89+
const result = await adapter.verifyIdToken({
90+
id: 'mockUserId',
91+
id_token: 'the_token',
92+
});
93+
94+
expect(result).toEqual(fakeClaim);
95+
expect(jwt.verify.calls.first().args[2].algorithms).toEqual(['ES256']);
96+
});
97+
98+
it('should verify a valid id_token without an explicit id', async function () {
99+
const fakeClaim = mockEs256IdTokenVerification({ sub: 'line-subject' });
100+
101+
const result = await adapter.verifyIdToken({ id_token: 'the_token' });
102+
103+
expect(result).toEqual(fakeClaim);
104+
});
105+
106+
it('should reject a token with an invalid issuer', async function () {
107+
mockEs256IdTokenVerification({ iss: 'https://invalid.line.me' });
108+
109+
await expectAsync(
110+
adapter.verifyIdToken({ id: 'mockUserId', id_token: 'the_token' })
111+
).toBeRejectedWithError(
112+
'id token not issued by correct OpenID provider - expected: https://access.line.me | from: https://invalid.line.me'
113+
);
114+
});
115+
116+
it('should reject a token with a mismatched sub claim', async function () {
117+
mockEs256IdTokenVerification({ sub: 'another-user' });
118+
119+
await expectAsync(
120+
adapter.verifyIdToken({ id: 'mockUserId', id_token: 'the_token' })
121+
).toBeRejectedWithError('auth data is invalid for this user.');
122+
});
123+
124+
it('should reject a token with a mismatched nonce', async function () {
125+
mockEs256IdTokenVerification({ nonce: 'server-nonce' });
126+
127+
await expectAsync(
128+
adapter.verifyIdToken({
129+
id_token: 'the_token',
130+
nonce: 'different-nonce',
131+
})
132+
).toBeRejectedWithError('auth data is invalid for this user.');
133+
});
134+
135+
it('should verify an HS256 token when clientSecret is configured', async function () {
136+
spyOn(authUtils, 'getHeaderFromToken').and.returnValue({ alg: 'HS256' });
137+
spyOn(jwt, 'verify').and.returnValue({
138+
iss: 'https://access.line.me',
139+
aud: 'validClientId',
140+
exp: Date.now() + 1000,
141+
sub: 'mockUserId',
142+
});
143+
144+
const result = await adapter.verifyIdToken({
145+
id: 'mockUserId',
146+
id_token: 'the_token',
147+
});
148+
149+
expect(result.sub).toBe('mockUserId');
150+
expect(jwt.verify.calls.first().args[1]).toBe('validClientSecret');
151+
expect(jwt.verify.calls.first().args[2].algorithms).toEqual(['HS256']);
152+
});
153+
154+
it('should reject an HS256 token when clientSecret is missing', async function () {
155+
adapter.validateOptions({ clientId: 'validClientId' });
156+
spyOn(authUtils, 'getHeaderFromToken').and.returnValue({ alg: 'HS256' });
157+
158+
await expectAsync(adapter.verifyIdToken({ id_token: 'the_token' })).toBeRejectedWithError(
159+
'Line clientSecret is required to verify HS256 id_token.'
160+
);
161+
});
162+
});
163+
11164
describe('getAccessTokenFromCode', function () {
165+
beforeEach(function () {
166+
adapter.validateOptions(validOptions);
167+
});
168+
12169
it('should throw an error if code is missing in authData', async function () {
13170
const authData = { redirect_uri: 'http://example.com' };
14171

@@ -17,6 +174,14 @@ describe('LineAdapter', function () {
17174
);
18175
});
19176

177+
it('should throw an error if clientSecret is missing in server-side auth flows', async function () {
178+
adapter.validateOptions({ clientId: 'validClientId' });
179+
180+
await expectAsync(
181+
adapter.getAccessTokenFromCode({ code: 'validCode', redirect_uri: 'http://example.com' })
182+
).toBeRejectedWithError('Line clientSecret is required to exchange code for token.');
183+
});
184+
20185
it('should fetch an access token successfully', async function () {
21186
mockFetch([
22187
{
@@ -92,7 +257,11 @@ describe('LineAdapter', function () {
92257
});
93258

94259
describe('getUserFromAccessToken', function () {
95-
it('should fetch user data successfully', async function () {
260+
beforeEach(function () {
261+
adapter.validateOptions(validOptions);
262+
});
263+
264+
it('should fetch user data successfully and normalize the user id', async function () {
96265
mockFetch([
97266
{
98267
url: 'https://api.line.me/v2/profile',
@@ -114,6 +283,7 @@ describe('LineAdapter', function () {
114283
expect(user).toEqual({
115284
userId: 'mockUserId',
116285
displayName: 'mockDisplayName',
286+
id: 'mockUserId',
117287
});
118288
});
119289

@@ -130,7 +300,7 @@ describe('LineAdapter', function () {
130300
]);
131301

132302
const accessToken = 'invalidAccessToken';
133-
303+
134304
await expectAsync(adapter.getUserFromAccessToken(accessToken)).toBeRejectedWithError(
135305
'Failed to fetch Line user: Unauthorized'
136306
);
@@ -156,6 +326,35 @@ describe('LineAdapter', function () {
156326
});
157327
});
158328

329+
describe('beforeFind', function () {
330+
beforeEach(function () {
331+
adapter.validateOptions(validOptions);
332+
});
333+
334+
it('should populate authData.id from a verified id_token', async function () {
335+
spyOn(adapter, 'verifyIdToken').and.resolveTo({ sub: 'mockUserId' });
336+
const authData = {
337+
id_token: 'the_token',
338+
nonce: 'nonce',
339+
};
340+
341+
await adapter.beforeFind(authData);
342+
343+
expect(authData).toEqual({ id: 'mockUserId' });
344+
});
345+
346+
it('should block insecure auth unless explicitly enabled', async function () {
347+
const authData = {
348+
id: 'mockUserId',
349+
access_token: 'validAccessToken',
350+
};
351+
352+
await expectAsync(adapter.beforeFind(authData)).toBeRejectedWithError(
353+
'Line code is required.'
354+
);
355+
});
356+
});
357+
159358
describe('LineAdapter E2E Test', function () {
160359
beforeEach(async function () {
161360
await reconfigureServer({
@@ -306,4 +505,136 @@ describe('LineAdapter', function () {
306505
});
307506
});
308507

508+
describe('LineAdapter E2E id_token Test', function () {
509+
beforeEach(async function () {
510+
await reconfigureServer({
511+
auth: {
512+
line: {
513+
clientId: 'validClientId',
514+
},
515+
},
516+
});
517+
});
518+
519+
it('should log in user successfully with a valid id_token', async function () {
520+
mockEs256IdTokenVerification();
521+
522+
const authData = {
523+
id_token: 'the_token',
524+
};
525+
526+
const user = await Parse.User.logInWith('line', { authData });
527+
await user.fetch({ useMasterKey: true });
528+
529+
expect(user.id).toBeDefined();
530+
expect(user.get('authData').line.id).toBe('mockUserId');
531+
});
532+
533+
it('should link line auth to an existing logged-in user with an id_token', async function () {
534+
mockEs256IdTokenVerification({ sub: 'link-user-id' });
535+
const user = await Parse.User.signUp('line-link-user', 'password');
536+
537+
await user.save(
538+
{
539+
authData: {
540+
line: {
541+
id_token: 'the_token',
542+
},
543+
},
544+
},
545+
{ sessionToken: user.getSessionToken() }
546+
);
547+
548+
await user.fetch({ useMasterKey: true });
549+
expect(user.get('authData').line.id).toBe('link-user-id');
550+
});
551+
552+
it('should allow updating existing LINE auth with id_token', async function () {
553+
mockEs256IdTokenVerification({ sub: 'existing-line-user' });
554+
const user = await Parse.User.logInWith('line', {
555+
authData: {
556+
id_token: 'first_token',
557+
},
558+
});
559+
560+
mockEs256IdTokenVerification({ sub: 'existing-line-user' });
561+
await user.save(
562+
{
563+
authData: {
564+
line: {
565+
id_token: 'second_token',
566+
},
567+
},
568+
},
569+
{ sessionToken: user.getSessionToken() }
570+
);
571+
572+
await user.fetch({ useMasterKey: true });
573+
expect(user.get('authData').line.id).toBe('existing-line-user');
574+
});
575+
576+
it('should reject insecure authData when insecure auth is disabled', async function () {
577+
await expectAsync(
578+
Parse.User.logInWith('line', {
579+
authData: {
580+
id: 'mockUserId',
581+
access_token: 'validAccessToken',
582+
},
583+
})
584+
).toBeRejectedWithError('Line code is required.');
585+
});
586+
587+
it('should handle invalid id_token claims during login', async function () {
588+
mockEs256IdTokenVerification({ iss: 'https://invalid.line.me' });
589+
590+
await expectAsync(
591+
Parse.User.logInWith('line', {
592+
authData: {
593+
id_token: 'the_token',
594+
},
595+
})
596+
).toBeRejectedWithError(
597+
'id token not issued by correct OpenID provider - expected: https://access.line.me | from: https://invalid.line.me'
598+
);
599+
});
600+
});
601+
602+
describe('LineAdapter E2E legacy access token Test', function () {
603+
beforeEach(async function () {
604+
await reconfigureServer({
605+
auth: {
606+
line: {
607+
enableInsecureAuth: true,
608+
},
609+
},
610+
});
611+
});
612+
613+
it('should allow insecure auth only when explicitly enabled', async function () {
614+
mockFetch([
615+
{
616+
url: 'https://api.line.me/v2/profile',
617+
method: 'GET',
618+
response: {
619+
ok: true,
620+
json: () =>
621+
Promise.resolve({
622+
userId: 'mockUserId',
623+
displayName: 'mockDisplayName',
624+
}),
625+
},
626+
},
627+
]);
628+
629+
const user = await Parse.User.logInWith('line', {
630+
authData: {
631+
id: 'mockUserId',
632+
access_token: 'validAccessToken',
633+
},
634+
});
635+
636+
expect(user.id).toBeDefined();
637+
expect(user.get('authData').line.id).toBe('mockUserId');
638+
});
639+
});
309640
});

0 commit comments

Comments
 (0)