diff --git a/spec/Adapters/Auth/line.spec.js b/spec/Adapters/Auth/line.spec.js index bde4c906b8..ce8a5ba1a9 100644 --- a/spec/Adapters/Auth/line.spec.js +++ b/spec/Adapters/Auth/line.spec.js @@ -1,14 +1,172 @@ const LineAdapter = require('../../../lib/Adapters/Auth/line').default; +const jwt = require('jsonwebtoken'); +const authUtils = require('../../../lib/Adapters/Auth/utils'); + describe('LineAdapter', function () { let adapter; + const validOptions = { + clientId: 'validClientId', + clientSecret: 'validClientSecret', + }; + + // Stub LINE JWKS lookup and JWT verification so auth-flow tests stay deterministic + // and do not depend on live LINE signing keys. + function mockEs256IdTokenVerification(claims = {}) { + const jwtClaims = { + iss: 'https://access.line.me', + aud: 'validClientId', + exp: Math.floor(Date.now() / 1000) + 3600, + sub: 'mockUserId', + ...claims, + }; + const getHeaderSpy = jasmine.isSpy(authUtils.getHeaderFromToken) + ? authUtils.getHeaderFromToken + : spyOn(authUtils, 'getHeaderFromToken'); + const getSigningKeySpy = jasmine.isSpy(authUtils.getSigningKey) + ? authUtils.getSigningKey + : spyOn(authUtils, 'getSigningKey'); + const verifySpy = jasmine.isSpy(jwt.verify) ? jwt.verify : spyOn(jwt, 'verify'); + getHeaderSpy.and.returnValue({ kid: '123', alg: 'ES256' }); + getSigningKeySpy.and.resolveTo({ publicKey: 'line_public_key' }); + verifySpy.and.returnValue(jwtClaims); + return jwtClaims; + } + beforeEach(function () { adapter = new LineAdapter.constructor(); adapter.clientId = 'validClientId'; adapter.clientSecret = 'validClientSecret'; }); + describe('validateOptions', function () { + it('should allow secure id token validation with clientId only', function () { + expect(() => adapter.validateOptions({ clientId: 'validClientId' })).not.toThrow(); + expect(adapter.clientId).toBe('validClientId'); + expect(adapter.clientSecret).toBeUndefined(); + }); + + it('should allow insecure-only configuration when explicitly enabled', function () { + expect(() => adapter.validateOptions({ enableInsecureAuth: true })).not.toThrow(); + expect(adapter.enableInsecureAuth).toBeTrue(); + }); + + it('should require clientId when secure auth is configured', function () { + expect(() => adapter.validateOptions({ clientSecret: 'validClientSecret' })).toThrowError( + 'Line clientId is required.' + ); + }); + }); + + describe('verifyIdToken', function () { + beforeEach(function () { + adapter.validateOptions(validOptions); + }); + + it('should throw an error if id_token is missing', async function () { + await expectAsync(adapter.verifyIdToken({})).toBeRejectedWithError( + 'id token is invalid for this user.' + ); + }); + + it('should not decode an invalid id_token', async function () { + await expectAsync(adapter.verifyIdToken({ id_token: 'the_token' })).toBeRejectedWithError( + 'provided token does not decode as JWT' + ); + }); + + it('should throw an error if public key used to encode token is not available', async function () { + spyOn(authUtils, 'getHeaderFromToken').and.returnValue({ kid: '789', alg: 'ES256' }); + spyOn(authUtils, 'getSigningKey').and.returnValue(Promise.reject(new Error('missing key'))); + + await expectAsync(adapter.verifyIdToken({ id_token: 'the_token' })).toBeRejectedWithError( + 'Unable to find matching key for Key ID: 789' + ); + }); + + it('should guard and pass only a valid supported algorithm to jwt.verify', async function () { + const fakeClaim = mockEs256IdTokenVerification(); + + const result = await adapter.verifyIdToken({ + id: 'mockUserId', + id_token: 'the_token', + }); + + expect(result).toEqual(fakeClaim); + expect(jwt.verify.calls.first().args[2].algorithms).toEqual(['ES256']); + }); + + it('should verify a valid id_token without an explicit id', async function () { + const fakeClaim = mockEs256IdTokenVerification({ sub: 'line-subject' }); + + const result = await adapter.verifyIdToken({ id_token: 'the_token' }); + + expect(result).toEqual(fakeClaim); + }); + + it('should reject a token with an invalid issuer', async function () { + mockEs256IdTokenVerification({ iss: 'https://invalid.line.me' }); + + await expectAsync( + adapter.verifyIdToken({ id: 'mockUserId', id_token: 'the_token' }) + ).toBeRejectedWithError( + 'id token not issued by correct OpenID provider - expected: https://access.line.me | from: https://invalid.line.me' + ); + }); + + it('should reject a token with a mismatched sub claim', async function () { + mockEs256IdTokenVerification({ sub: 'another-user' }); + + await expectAsync( + adapter.verifyIdToken({ id: 'mockUserId', id_token: 'the_token' }) + ).toBeRejectedWithError('auth data is invalid for this user.'); + }); + + it('should reject a token with a mismatched nonce', async function () { + mockEs256IdTokenVerification({ nonce: 'server-nonce' }); + + await expectAsync( + adapter.verifyIdToken({ + id_token: 'the_token', + nonce: 'different-nonce', + }) + ).toBeRejectedWithError('auth data is invalid for this user.'); + }); + + it('should verify an HS256 token when clientSecret is configured', async function () { + spyOn(authUtils, 'getHeaderFromToken').and.returnValue({ alg: 'HS256' }); + spyOn(jwt, 'verify').and.returnValue({ + iss: 'https://access.line.me', + aud: 'validClientId', + exp: Date.now() + 1000, + sub: 'mockUserId', + }); + + const result = await adapter.verifyIdToken({ + id: 'mockUserId', + id_token: 'the_token', + }); + + expect(result.sub).toBe('mockUserId'); + expect(jwt.verify.calls.first().args[1]).toBe('validClientSecret'); + expect(jwt.verify.calls.first().args[2].algorithms).toEqual(['HS256']); + }); + + it('should reject an HS256 token when clientSecret is missing', async function () { + adapter.validateOptions({ clientId: 'validClientId' }); + spyOn(authUtils, 'getHeaderFromToken').and.returnValue({ alg: 'HS256' }); + + await expectAsync(adapter.verifyIdToken({ id_token: 'the_token' })).toBeRejectedWithError( + 'Line clientSecret is required to verify HS256 id_token.' + ); + }); + }); + describe('getAccessTokenFromCode', function () { + beforeEach(function () { + adapter.validateOptions(validOptions); + }); + it('should throw an error if code is missing in authData', async function () { const authData = { redirect_uri: 'http://example.com' }; @@ -17,6 +175,14 @@ describe('LineAdapter', function () { ); }); + it('should throw an error if clientSecret is missing in server-side auth flows', async function () { + adapter.validateOptions({ clientId: 'validClientId' }); + + await expectAsync( + adapter.getAccessTokenFromCode({ code: 'validCode', redirect_uri: 'http://example.com' }) + ).toBeRejectedWithError('Line clientSecret is required to exchange code for token.'); + }); + it('should fetch an access token successfully', async function () { mockFetch([ { @@ -92,7 +258,11 @@ describe('LineAdapter', function () { }); describe('getUserFromAccessToken', function () { - it('should fetch user data successfully', async function () { + beforeEach(function () { + adapter.validateOptions(validOptions); + }); + + it('should fetch user data successfully and normalize the user id', async function () { mockFetch([ { url: 'https://api.line.me/v2/profile', @@ -114,6 +284,7 @@ describe('LineAdapter', function () { expect(user).toEqual({ userId: 'mockUserId', displayName: 'mockDisplayName', + id: 'mockUserId', }); }); @@ -130,7 +301,7 @@ describe('LineAdapter', function () { ]); const accessToken = 'invalidAccessToken'; - + await expectAsync(adapter.getUserFromAccessToken(accessToken)).toBeRejectedWithError( 'Failed to fetch Line user: Unauthorized' ); @@ -156,6 +327,35 @@ describe('LineAdapter', function () { }); }); + describe('beforeFind', function () { + beforeEach(function () { + adapter.validateOptions(validOptions); + }); + + it('should populate authData.id from a verified id_token', async function () { + spyOn(adapter, 'verifyIdToken').and.resolveTo({ sub: 'mockUserId' }); + const authData = { + id_token: 'the_token', + nonce: 'nonce', + }; + + await adapter.beforeFind(authData); + + expect(authData).toEqual({ id: 'mockUserId' }); + }); + + it('should block insecure auth unless explicitly enabled', async function () { + const authData = { + id: 'mockUserId', + access_token: 'validAccessToken', + }; + + await expectAsync(adapter.beforeFind(authData)).toBeRejectedWithError( + 'Line code is required.' + ); + }); + }); + describe('LineAdapter E2E Test', function () { beforeEach(async function () { await reconfigureServer({ @@ -306,4 +506,136 @@ describe('LineAdapter', function () { }); }); + describe('LineAdapter E2E id_token Test', function () { + beforeEach(async function () { + await reconfigureServer({ + auth: { + line: { + clientId: 'validClientId', + }, + }, + }); + }); + + it('should log in user successfully with a valid id_token', async function () { + mockEs256IdTokenVerification(); + + const authData = { + id_token: 'the_token', + }; + + const user = await Parse.User.logInWith('line', { authData }); + await user.fetch({ useMasterKey: true }); + + expect(user.id).toBeDefined(); + expect(user.get('authData').line.id).toBe('mockUserId'); + }); + + it('should link line auth to an existing logged-in user with an id_token', async function () { + mockEs256IdTokenVerification({ sub: 'link-user-id' }); + const user = await Parse.User.signUp('line-link-user', 'password'); + + await user.save( + { + authData: { + line: { + id_token: 'the_token', + }, + }, + }, + { sessionToken: user.getSessionToken() } + ); + + await user.fetch({ useMasterKey: true }); + expect(user.get('authData').line.id).toBe('link-user-id'); + }); + + it('should allow updating existing LINE auth with id_token', async function () { + mockEs256IdTokenVerification({ sub: 'existing-line-user' }); + const user = await Parse.User.logInWith('line', { + authData: { + id_token: 'first_token', + }, + }); + + mockEs256IdTokenVerification({ sub: 'existing-line-user' }); + await user.save( + { + authData: { + line: { + id_token: 'second_token', + }, + }, + }, + { sessionToken: user.getSessionToken() } + ); + + await user.fetch({ useMasterKey: true }); + expect(user.get('authData').line.id).toBe('existing-line-user'); + }); + + it('should reject insecure authData when insecure auth is disabled', async function () { + await expectAsync( + Parse.User.logInWith('line', { + authData: { + id: 'mockUserId', + access_token: 'validAccessToken', + }, + }) + ).toBeRejectedWithError('Line code is required.'); + }); + + it('should handle invalid id_token claims during login', async function () { + mockEs256IdTokenVerification({ iss: 'https://invalid.line.me' }); + + await expectAsync( + Parse.User.logInWith('line', { + authData: { + id_token: 'the_token', + }, + }) + ).toBeRejectedWithError( + 'id token not issued by correct OpenID provider - expected: https://access.line.me | from: https://invalid.line.me' + ); + }); + }); + + describe('LineAdapter E2E legacy access token Test', function () { + beforeEach(async function () { + await reconfigureServer({ + auth: { + line: { + enableInsecureAuth: true, + }, + }, + }); + }); + + it('should allow insecure auth only when explicitly enabled', async function () { + mockFetch([ + { + url: 'https://api.line.me/v2/profile', + method: 'GET', + response: { + ok: true, + json: () => + Promise.resolve({ + userId: 'mockUserId', + displayName: 'mockDisplayName', + }), + }, + }, + ]); + + const user = await Parse.User.logInWith('line', { + authData: { + id: 'mockUserId', + access_token: 'validAccessToken', + }, + }); + + expect(user.id).toBeDefined(); + expect(user.get('authData').line.id).toBe('mockUserId'); + }); + }); }); diff --git a/src/Adapters/Auth/line.js b/src/Adapters/Auth/line.js index 7551db817d..fd5b0baa92 100644 --- a/src/Adapters/Auth/line.js +++ b/src/Adapters/Auth/line.js @@ -4,7 +4,7 @@ * @class LineAdapter * @param {Object} options - The adapter configuration options. * @param {string} options.clientId - Your Line App Client ID. Required for secure authentication. - * @param {string} options.clientSecret - Your Line App Client Secret. Required for secure authentication. + * @param {string} [options.clientSecret] - Your Line App Client Secret. Required for authorization code exchange and HS256 token verification. * @param {boolean} [options.enableInsecureAuth=false] - **[DEPRECATED]** Enable insecure authentication (not recommended). * * @description @@ -15,6 +15,16 @@ * { * "auth": { * "line": { + * "clientId": "your-client-id" + * } + * } + * } + * ``` + * Add `clientSecret` when you also want Parse Server to exchange authorization codes: + * ```json + * { + * "auth": { + * "line": { * "clientId": "your-client-id", * "clientSecret": "your-client-secret" * } @@ -33,10 +43,21 @@ * ``` * * The adapter requires the following `authData` fields: + * - **Secure Authentication**: `id_token`, optionally `id` and `nonce`. * - **Secure Authentication**: `code`, `redirect_uri`. * - **Insecure Authentication (Not Recommended)**: `id`, `access_token`. * * ## Auth Payloads + * ### Secure ID Token Payload + * ```json + * { + * "line": { + * "id": "1234567", + * "id_token": "xxxxx.yyyyy.zzzzz" + * } + * } + * ``` + * * ### Secure Authentication Payload * ```json * { @@ -58,19 +79,171 @@ * ``` * * ## Notes - * - `enableInsecureAuth` is **not recommended** and will be removed in future versions. Use secure authentication with `clientId` and `clientSecret`. - * - Secure authentication exchanges the `code` and `redirect_uri` provided by the client for an access token using Line's OAuth flow. + * - `enableInsecureAuth` is **not recommended** and will be removed in future versions. + * - Secure authentication can validate a client-provided `id_token` locally when the token is signed with LINE's OIDC keys. + * - Secure authentication exchanges the `code` and `redirect_uri` provided by the client for an access token using LINE's OAuth flow. * * @see {@link https://developers.line.biz/en/docs/line-login/integrate-line-login/ Line Login Documentation} */ import BaseCodeAuthAdapter from './BaseCodeAuthAdapter'; +const Parse = require('parse/node').Parse; +const jwksClient = require('jwks-rsa'); +const jwt = require('jsonwebtoken'); +const authUtils = require('./utils'); + +// LINE documents the OIDC issuer as `https://access.line.me` in both: +// https://developers.line.biz/en/docs/line-login/verify-id-token/ +// https://access.line.me/.well-known/openid-configuration +const TOKEN_ISSUER = 'https://access.line.me'; +const ONE_HOUR_IN_MS = 3600000; + +/** + * Resolves the LINE signing key for an ES256 `id_token` using the token header `kid`. + * (responses are cached the same way the other auth adapters do) + * @returns The signing key returned by `jwks-rsa`. + */ +const getLineKeyByKeyId = async ( + /** @type {string} The JWT header `kid`. */ + keyId, + /** Maximum number of cached JWKS entries */ + cacheMaxEntries, + /** Maximum JWKS cache age in milliseconds */ + cacheMaxAge) => { + const client = jwksClient({ + jwksUri: 'https://api.line.me/oauth2/v2.1/certs', + cache: true, + cacheMaxEntries, + cacheMaxAge, + }); + + let key; + try { + key = await authUtils.getSigningKey(client, keyId); + } catch { + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + `Unable to find matching key for Key ID: ${keyId}` + ); + } + return key; +}; class LineAdapter extends BaseCodeAuthAdapter { constructor() { super('Line'); } + validateOptions(options) { + if (!options) { + throw new Error('Line options are required.'); + } + + this.enableInsecureAuth = options.enableInsecureAuth; + this.clientId = options.clientId; + this.clientSecret = options.clientSecret; + this.cacheMaxEntries = options.cacheMaxEntries; + this.cacheMaxAge = options.cacheMaxAge; + + // Keep the legacy insecure mode backward-compatible when a deployment opts in + // to `enableInsecureAuth` only and does not want to configure OIDC/code flow. + if (this.enableInsecureAuth && !this.clientId && !this.clientSecret) { + return; + } + + // `clientId` is the minimum requirement for secure LINE auth because both + // `id_token` audience checks and code-flow token exchange are channel-bound. + if (!this.clientId) { + throw new Error('Line clientId is required.'); + } + } + + /** + * Validates a LINE OpenID Connect `id_token` and returns the verified claims. + * Supports LINE's ES256 native/LIFF tokens and HS256 web-login tokens. + * authData payload : id_token + id + nonce; strings. + * @returns {Promise} The verified JWT claims. + */ + async verifyIdToken({ id_token: token, id, nonce }) { + if (!this.clientId) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Line auth is not configured.'); + } + + if (!token) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'id token is invalid for this user.'); + } + + const { kid: keyId, alg } = authUtils.getHeaderFromToken(token); + const cacheMaxAge = this.cacheMaxAge || ONE_HOUR_IN_MS; + const cacheMaxEntries = this.cacheMaxEntries || 5; + let jwtClaims; + + try { + // Read the alg field, but guard algorithms to ones LINE actually supports/documents. + if (alg === 'ES256') { + const lineKey = await getLineKeyByKeyId(keyId, cacheMaxEntries, cacheMaxAge); + const signingKey = lineKey.publicKey || lineKey.rsaPublicKey; + jwtClaims = jwt.verify(token, signingKey, { + algorithms: ['ES256'], + audience: this.clientId, + }); + } else if (alg === 'HS256') { + if (!this.clientSecret) { + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Line clientSecret is required to verify HS256 id_token.' + ); + } + jwtClaims = jwt.verify(token, this.clientSecret, { + algorithms: ['HS256'], + audience: this.clientId, + }); + } else { + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + `Unsupported Line id_token signing algorithm: ${alg}` + ); + } + } catch (exception) { + const message = exception.message; + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, `${message}`); + } + + if (jwtClaims.iss !== TOKEN_ISSUER) { + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + `id token not issued by correct OpenID provider - expected: ${TOKEN_ISSUER} | from: ${jwtClaims.iss}` + ); + } + + if (id && jwtClaims.sub !== id) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'auth data is invalid for this user.'); + } + + if (nonce && jwtClaims.nonce !== nonce) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'auth data is invalid for this user.'); + } + + return jwtClaims; + } + + async beforeFind(authData) { + if (authData?.id_token) { + // Set authData.id from the verified LINE token subject if id_token exists and is valid. + const jwtClaims = await this.verifyIdToken(authData); + authData.id = jwtClaims.sub; + delete authData.id_token; + delete authData.nonce; + return; + } + + return super.beforeFind(authData); + } + + /** + * Exchanges a LINE authorization code for an access token. + * authData = code + redirect_uri + */ async getAccessTokenFromCode(authData) { if (!authData.code) { throw new Parse.Error( @@ -79,6 +252,15 @@ class LineAdapter extends BaseCodeAuthAdapter { ); } + // Assert clientSecret is present for code exchange flows. + // id_token verification does not use it, so it is now optional and needs a check. + if (!this.clientSecret) { + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Line clientSecret is required to exchange code for token.' + ); + } + const tokenUrl = 'https://api.line.me/oauth2/v2.1/token'; const response = await fetch(tokenUrl, { method: 'POST', @@ -112,6 +294,10 @@ class LineAdapter extends BaseCodeAuthAdapter { return data.access_token; } + /** + * fetches the LINE profile associated with an access token + * Also normalizes provider response into Parse's expected `{ id }` auth shape. + */ async getUserFromAccessToken(accessToken) { const userApiUrl = 'https://api.line.me/v2/profile'; const response = await fetch(userApiUrl, { @@ -136,7 +322,13 @@ class LineAdapter extends BaseCodeAuthAdapter { ); } - return userData; + return { + ...userData, + // LINE profile responses return `userId`: + // https://developers.line.biz/en/reference/line-login/#get-user-profile + // BaseCodeAuthAdapter expects `id`. + id: userData.userId, + }; } }