diff --git a/API.md b/API.md index 4091259..2a79fc4 100644 --- a/API.md +++ b/API.md @@ -402,6 +402,98 @@ When this request is recieved the service does the following: } ``` + +## Request Instagram Verification + +When this request is made the service stores the `did`, `username`, and a *`timestamp`* in it's database of requested instagram verifications. + +Note: due to the OAuth Authorization code flow, the service can provide a convenient HTTP redirection 307 to user by setting the env variable `INSTAGRAM_HTTP_REDIRECT=true` + +**Endpoint:** `GET /api/v0/request-instagram` + +**Query params:** + +```jsx + - did: + - username: +``` + +Example `/api/v0/request-instagram?username=&did=` + +**Response:** + +With `INSTAGRAM_HTTP_REDIRECT=true`, a redirection to [Instagram Authorization Window](https://developers.facebook.com/docs/instagram-basic-display-api/overview/#authorization-window). + +Otherwise: + +```jsx +{ + "status": "success", + "data": { + "statusCode": 307, + "headers": { + "Location": "https://api.instagram.com/oauth/authorize/?client_id=&redirect_uri=&scope=user_profile&response_type=code&state=" + }, + "body": "" + } +} +``` + +## Confirm Instagram Verification + +When this request is received the service does the following: + +1. Validate that the JWS has a correct signature (is signed by the DID in the `kid` property of the JWS) +2. Retrieve the stored request from the database using the DID part of the `kid` if present, otherwise respond with an error +3. Verify that the JWS has content equal to the `challenge-code`, otherwise return error +4. Verify that the *`timestamp`* is from less than 30 minutes ago +5. Call Instagram OAuth API to convert Authorization code to Oauth access token +6. Get Instagram User profile from the Graph API (`/me`) with the previous access token and +7. Verify that the username from Instagram authenticated response is equal to the stored one. +8. Create a Verifiable Credential with the content described below, sign it with the service key (web-did), and send this as the response + +**Endpoint:** `POST /api/v0/confirm-instagram` + +**Body:** + +```jsx +{ + code: + jws: +} +``` + +**Response:** + +```jsx +{ + status: 'success', + data: { + attestation: + } +} +``` + +**Verifiable Credential content:** + +```jsx +{ + sub: , + nbf: 1562950282, // Time jwt was issued + vc: { + '@context': ['https://www.w3.org/2018/credentials/v1'], + type: ['VerifiableCredential'], + credentialSubject: { + account: { + type: 'Instagram', + username: , + userId: + } + } + } +} +``` + # User flows These user flows describe high level user interactions needed to facilitate the verifications. They are mainly meant to illustrate the rough flow so that individual steps that happen in the background can be more easily understood, which is useful if you just want to write a simple test that validates that the services work. The actual user facing implementation can have more optimized UX (e.g. automatically populating a tweet). @@ -447,6 +539,18 @@ These user flows describe high level user interactions needed to facilitate the 1. A JWS containing the *challenge code* is created using the js-did library 2. The JWS is sent to the *confirm discourse* endpoint and the Verifiable Credential is returned +## Instagram verification + +1. User inputs their instagram username and clicks verify + 1. A request with users DID, instagram username is made to the *request instagram* endpoint + 2. The returned *challenge code* is temporarily stored +2. User is redirected to [Instagram Authorization window](https://developers.facebook.com/docs/instagram-basic-display-api/overview/#authorization-window) on Instagram's website to login (if not already logged-in) and approve the request of information access. +This can be done by an HTTP redirect if `INSTAGRAM_HTTP_REDIRECT` env var is set or by the verification website. +3. Instagram Auth API redirects to the `INSTAGRAM_REDIRECT_URI` specified in .env and the Instagram Client App settings. The redirection is done with an OAuth2 authorization `code` in query param and `state` containing the *challenge code*. +4. User clicks verify and they now get the Verifiable credential back from the service + 1. A JWS containing the *challenge code* is created using the js-did library + 2. The JWS and the OAuth2 authorization code are sent to the *confirm isntagram* endpoint and the Verifiable Credential is returned + # Implementation details Here is a few details that will help with the implementation and in particular related to the DID and JWS stuff. Note that some of these are new libraries so definitely reach out if something seems off! diff --git a/README.md b/README.md index 9f9fb5e..0ea2b15 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ -> Services for issuing verifiable credentials that link a decentralized identifier (DID) to various social accounts including Twitter, Github, and Discord. Additional account types can be added in the future. +> Services for issuing verifiable credentials that link a decentralized identifier (DID) to various social accounts including Twitter, Github, Instagram and Discord. Additional account types can be added in the future. ## What's included diff --git a/packages/server/.template.env b/packages/server/.template.env index 8ee5ef3..dcc36c1 100644 --- a/packages/server/.template.env +++ b/packages/server/.template.env @@ -10,3 +10,9 @@ TWITTER_CONSUMER_SECRET= TWITTER_ACCESS_TOKEN= TWITTER_ACCESS_TOKEN_SECRET= SEGMENT_WRITE_KEY= +INSTAGRAM_CLIENT_ID= +INSTAGRAM_CLIENT_SECRET= +# Redirect URI should match with the registered one in Instagram App on Facebook Developers portal +INSTAGRAM_REDIRECT_URI= +# Choose if you want to be redirected to Instagram Auth on calling GET /api/v0/request-instagram, or if you want a JSON +INSTAGRAM_HTTP_REDIRECT=false diff --git a/packages/server/README.md b/packages/server/README.md index 8181f33..7d73b99 100644 --- a/packages/server/README.md +++ b/packages/server/README.md @@ -12,7 +12,7 @@ -> A decentralized identifier (DID) verification service for Ceramic. Available methods include Twitter, Discord, and Github. +> A decentralized identifier (DID) verification service for Ceramic. Available methods include Twitter, Discord, Instagram and Github. ## Install @@ -27,6 +27,7 @@ Copy `.template.env` to `.env` and update the variables. You'll need the followi - Ceramic client url to resolve `@ceramicnetwork/3id-did-resolver` - Twitter developer tokens (you need all 4 items) - Github account username & API token. "Account Settings" > "Developer settings" > "Personal access tokens" +- Instagram app id & secret from [Facebook Apps](https://developers.facebook.com/apps/) and a registered redirect URI - Redis database URL & password - (optional) Segment token diff --git a/packages/server/package.json b/packages/server/package.json index 580b67b..9123041 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -6,7 +6,9 @@ "scripts": { "lint": "eslint ./src ", "test": "jest --testPathPattern=src/ --detectOpenHandles --setupFiles dotenv/config", - "coverage": "jest --coverage" + "coverage": "jest --coverage", + "redis:docker": "docker run -d -h redis -e REDIS_PASSWORD=redis -v redis-data:/data -p 6379:6379 --name redis redis /bin/sh -c 'redis-server --appendonly yes --requirepass ${REDIS_PASSWORD}'", + "start": "sls offline --host 0.0.0.0 --port 3080 --trace-warnings" }, "repository": "git@github.com:ceramicstudio/identitylink-services.git", "author": "Patrick Gallagher ", @@ -49,4 +51,4 @@ "serverless-offline": "^5.10.1", "standard": "^14.0.0" } -} +} \ No newline at end of file diff --git a/packages/server/serverless.yml b/packages/server/serverless.yml index f7f1acf..a5d76c1 100644 --- a/packages/server/serverless.yml +++ b/packages/server/serverless.yml @@ -25,6 +25,10 @@ provider: TWITTER_CONSUMER_SECRET: ${self:custom.secrets.TWITTER_CONSUMER_SECRET} TWITTER_ACCESS_TOKEN: ${self:custom.secrets.TWITTER_ACCESS_TOKEN} TWITTER_ACCESS_TOKEN_SECRET: ${self:custom.secrets.TWITTER_ACCESS_TOKEN_SECRET} + INSTAGRAM_CLIENT_ID: ${self:custom.secrets.INSTAGRAM_CLIENT_ID} + INSTAGRAM_CLIENT_SECRET: ${self:custom.secrets.INSTAGRAM_CLIENT_SECRET} + INSTAGRAM_REDIRECT_URI: ${self:custom.secrets.INSTAGRAM_REDIRECT_URI} + INSTAGRAM_HTTP_REDIRECT: ${self:custom.secrets.INSTAGRAM_HTTP_REDIRECT} # Enable auto-packing of external modules custom: @@ -116,6 +120,24 @@ functions: method: post cors: true path: /api/v0/request-twitter + request-instagram: + handler: src/api_handler.request_instagram + timeout: 30 + vpc: + securityGroupIds: + - Fn::GetAtt: [ ServerlessSecurityGroup, GroupId ] + subnetIds: + - Ref: PrivateSubnetA + events: + - http: + method: get + cors: true + path: /api/v0/request-instagram + request: + parameters: + querystrings: + did: true + username: true verify-twitter: handler: src/api_handler.verify_twitter timeout: 30 @@ -155,6 +177,19 @@ functions: method: post cors: true path: /api/v0/confirm-telegram + verify-instagram: + handler: src/api_handler.verify_instagram + timeout: 30 + vpc: + securityGroupIds: + - Fn::GetAtt: [ ServerlessSecurityGroup, GroupId ] + subnetIds: + - Ref: PrivateSubnetA + events: + - http: + method: post + cors: true + path: /api/v0/confirm-instagram resources: Resources: ${file(cf-resources.yml)} diff --git a/packages/server/src/__tests__/api_handler.test.js b/packages/server/src/__tests__/api_handler.test.js index 8c7e5e8..4e76c3f 100644 --- a/packages/server/src/__tests__/api_handler.test.js +++ b/packages/server/src/__tests__/api_handler.test.js @@ -8,7 +8,10 @@ describe('apiHandler', () => { TWITTER_CONSUMER_SECRET: 'FAKE', KEYPAIR_PRIVATE_KEY: '4baba8f4a', KEYPAIR_PUBLIC_KEY: '04fff936f805ee2', - GITHUB_PERSONAL_ACCESS_TOKEN: 'FAKE' + GITHUB_PERSONAL_ACCESS_TOKEN: 'FAKE', + INSTAGRAM_CLIENT_ID: '123', + INSTAGRAM_CLIENT_SECRET: 'secret', + INSTAGRAM_REDIRECT_URI: 'http://my.url' } process.env.SECRETS = secrets }) @@ -76,4 +79,20 @@ describe('apiHandler', () => { done() }) }) + + test('request instagram', done => { + apiHandler.request_instagram({}, {}, (err, res) => { + expect(err).toBeNull() + expect(res).not.toBeNull() + done() + }) + }) + + test('verify instagram', done => { + apiHandler.verify_instagram({}, {}, (err, res) => { + expect(err).toBeNull() + expect(res).not.toBeNull() + done() + }) + }) }) diff --git a/packages/server/src/api/__tests__/instagram-request.test.js b/packages/server/src/api/__tests__/instagram-request.test.js new file mode 100644 index 0000000..7169f61 --- /dev/null +++ b/packages/server/src/api/__tests__/instagram-request.test.js @@ -0,0 +1,94 @@ +const InstagramRequestHandler = require('../instagram-request') + +describe('InstagramRequestHandler', () => { + let sut + let instagramMgrMock = { + generateRedirectionUrl: jest.fn(), + saveRequest: jest.fn() + } + let claimMgrMock = { issue: jest.fn() } + let analyticsMock = { trackRequestInstagram: jest.fn() } + + beforeAll(() => { + sut = new InstagramRequestHandler( + instagramMgrMock, + claimMgrMock, + analyticsMock + ) + }) + + test('empty constructor', () => { + expect(sut).not.toBeUndefined() + }) + + test('no did nor username', done => { + sut.handle({}, {}, (err, res) => { + expect(err).not.toBeNull() + expect(err.code).toEqual(400) + expect(err.message).toEqual('no did nor username') + done() + }) + }) + + test('no did', done => { + sut.handle( + { queryStringParameters: { username: 'anthony' } }, + {}, + (err, res) => { + expect(err).not.toBeNull() + expect(err.code).toEqual(403) + expect(err.message).toEqual('no did') + done() + } + ) + }) + + test('no username', done => { + sut.handle( + { + queryStringParameters: { did: 'did:123' } + }, + {}, + (err, res) => { + expect(err).not.toBeNull() + expect(err.code).toEqual(400) + expect(err.message).toEqual('no username') + done() + } + ) + }) + + test('happy path', done => { + instagramMgrMock.generateRedirectionUrl.mockReturnValue( + 'http://some.valid.url' + ) + sut.handle( + { + queryStringParameters: { username: 'wallkanda', did: 'did:123' } + }, + {}, + (_err, res) => { + expect(res).not.toBeNull() + // console.log(res) + expect(res.statusCode).toEqual(307) + expect(res.headers.Location).not.toBeNull() + done() + } + ) + }) + + // test('happy path with HTTP redirect', done => { + // sut.handle( + // { + // queryStringParameters: { username: 'wallkanda', did: 'did:123' } + // }, + // {}, + // (_err, res) => { + // expect(res).not.toBeNull() + // expect(res.status).toEqual(307) + // expect(res.headers.get('Location')).not.toBeNull() + // done() + // } + // ) + // }) +}) diff --git a/packages/server/src/api/__tests__/instagram-verify.test.js b/packages/server/src/api/__tests__/instagram-verify.test.js new file mode 100644 index 0000000..c6b09fb --- /dev/null +++ b/packages/server/src/api/__tests__/instagram-verify.test.js @@ -0,0 +1,73 @@ +const InstagramVerifyHandler = require('../instagram-verify') + +describe('InstagramVerifyHandler', () => { + let sut + let instagramMgrMock = { validateProfileFromAccount: jest.fn() } + let claimMgrMock = { issue: jest.fn(), verifyJWS: jest.fn() } + let analyticsMock = { trackVerifyInstagram: jest.fn() } + + beforeAll(() => { + sut = new InstagramVerifyHandler( + instagramMgrMock, + claimMgrMock, + analyticsMock + ) + }) + + test('empty constructor', () => { + expect(sut).not.toBeUndefined() + }) + + test('no jws', done => { + sut.handle( + { + body: JSON.stringify({ code: '123' }) + }, + {}, + (err, res) => { + expect(err).not.toBeNull() + expect(err.code).toEqual(400) + expect(err.message).toEqual('no jws') + done() + } + ) + }) + + test('no code', done => { + sut.handle( + { + body: JSON.stringify({ jws: 'abc123' }) + }, + {}, + (err, res) => { + expect(err).not.toBeNull() + expect(err.code).toEqual(400) + expect(err.message).toEqual('no code') + done() + } + ) + }) + + test('happy path', done => { + instagramMgrMock.validateProfileFromAccount.mockReturnValue({ + id: '123', + username: 'onetwothree' + }) + claimMgrMock.verifyJWS.mockReturnValue({ + payload: { challengeCode: '123' }, + did: 'did:123' + }) + claimMgrMock.issue.mockReturnValue('somejwttoken') + sut.handle( + { + body: JSON.stringify({ code: 'Azerty123', jws: 'abc123' }) + }, + {}, + (err, res) => { + expect(err).toBeNull() + expect(res).toEqual({ attestation: 'somejwttoken' }) + done() + } + ) + }) +}) diff --git a/packages/server/src/api/instagram-request.js b/packages/server/src/api/instagram-request.js new file mode 100644 index 0000000..4dbb705 --- /dev/null +++ b/packages/server/src/api/instagram-request.js @@ -0,0 +1,52 @@ +class InstagramRequestHandler { + constructor(instagramMgr, claimMgr, analytics) { + this.name = 'InstagramRequestHandler' + this.instagramMgr = instagramMgr + this.claimMgr = claimMgr + this.analytics = analytics + } + + async handle(event, context, cb) { + let did, username + if (event && event.queryStringParameters) { + did = event.queryStringParameters.did + username = event.queryStringParameters.username + } else { + cb({ code: 400, message: 'no did nor username' }) + return + } + + if (!did) { + cb({ code: 403, message: 'no did' }) + this.analytics.trackRequestInstagram(did, 403) + return + } + if (!username) { + cb({ code: 400, message: 'no username' }) + this.analytics.trackRequestInstagram(did, 400) + return + } + + let challengeCode = '' + try { + challengeCode = await this.instagramMgr.saveRequest(username, did) + } catch (e) { + console.error(e) + cb({ code: 500, message: `Error while trying save to Redis` }) + this.analytics.trackRequestInstagram(did, 500) + return + } + + const response = { + statusCode: 307, + headers: { + Location: this.instagramMgr.generateRedirectionUrl(challengeCode) + }, + body: '' + } + + this.analytics.trackRequestInstagram(did, 307) + cb(null, response) + } +} +module.exports = InstagramRequestHandler diff --git a/packages/server/src/api/instagram-verify.js b/packages/server/src/api/instagram-verify.js new file mode 100644 index 0000000..53654b9 --- /dev/null +++ b/packages/server/src/api/instagram-verify.js @@ -0,0 +1,81 @@ +class InstagramVerifyHandler { + constructor(instagramMgr, claimMgr, analytics) { + this.name = 'InstagramVerifyHandler' + this.instagramMgr = instagramMgr + this.claimMgr = claimMgr + this.analytics = analytics + } + + async handle(event, context, cb) { + let body + try { + body = JSON.parse(event.body) + } catch (e) { + cb({ code: 400, message: 'no json body: ' + e.toString() }) + return + } + + if (!body.jws) { + cb({ code: 400, message: 'no jws' }) + this.analytics.trackVerifyInstagram(body.jws, 400) + return + } + if (!body.code) { + cb({ code: 400, message: 'no code' }) + this.analytics.trackVerifyInstagram(body.jws, 400) + return + } + + let did = '' + let challengeCode = '' + const code = body.code + + try { + const unwrappped = await this.claimMgr.verifyJWS(body.jws) + challengeCode = unwrappped.payload.challengeCode + did = unwrappped.did + } catch (e) { + cb({ code: 500, message: 'error while trying to verify the JWS' }) + this.analytics.trackVerifyInstagram(body.jws, 500) + return + } + + let userId + let username = '' + try { + const me = await this.instagramMgr.validateProfileFromAccount( + did, + challengeCode, + code + ) + username = me.username + userId = me.id + } catch (e) { + cb({ + code: 500, + message: 'error while trying verify Instagram. ' + e + }) + this.analytics.trackVerifyInstagram(did, 500) + return + } + + let attestation = '' + + try { + attestation = await this.claimMgr.issue({ + did, + username, + userId, + type: 'Instagram' + }) + } catch (e) { + cb({ code: 500, message: 'could not issue a verification claim' + e }) + this.analytics.trackVerifyInstagram(did, 500) + return + } + + cb(null, { attestation }) + this.analytics.trackVerifyInstagram(did, 200) + } +} +module.exports = InstagramVerifyHandler diff --git a/packages/server/src/api_handler.js b/packages/server/src/api_handler.js index 38c92b9..6ccc114 100644 --- a/packages/server/src/api_handler.js +++ b/packages/server/src/api_handler.js @@ -7,6 +7,8 @@ const DiscordVerifyHandler = require('./api/discord-verify') const TelegramVerifyHandler = require('./api/telegram-verify') const DiscourseRequestHandler = require('./api/discourse-request') const DiscourseVerifyHandler = require('./api/discourse-verify') +const InstagramRequestHandler = require('./api/instagram-request') +const InstagramVerifyHandler = require('./api/instagram-verify') const DidDocumentHandler = require('./api/diddoc') const GithubMgr = require('./lib/githubMgr') @@ -14,6 +16,7 @@ const TwitterMgr = require('./lib/twitterMgr') const DiscordMgr = require('./lib/discordMgr') const TelegramMgr = require('./lib/telegramMgr') const DiscourseMgr = require('./lib/discourseMgr') +const InstagramMgr = require('./lib/instagramMgr') const ClaimMgr = require('./lib/claimMgr') const Analytics = require('./lib/analytics') @@ -22,6 +25,7 @@ let twitterMgr = new TwitterMgr() let discordMgr = new DiscordMgr() let telegramMgr = new TelegramMgr() let discourseMgr = new DiscourseMgr() +let instagramMgr = new InstagramMgr() let claimMgr = new ClaimMgr() const analytics = new Analytics() @@ -31,6 +35,13 @@ const doHandler = (handler, event, context, callback) => { let body = JSON.stringify({}) if (handler.name === 'DidDocumentHandler') { body = JSON.stringify(resp) + // Enable GET redirection for Instagram Oauth2 Authorization code flow + } else if ( + handler.name === 'InstagramRequestHandler' && + process.env.INSTAGRAM_HTTP_REDIRECT + ) { + callback(null, resp) + return } else { body = JSON.stringify({ status: 'success', @@ -79,7 +90,8 @@ const preHandler = (handler, event, context, callback) => { !claimMgr.isSecretsSet() || !githubMgr.isSecretsSet() || !discordMgr.isSecretsSet() || - !telegramMgr.isSecretsSet() + !telegramMgr.isSecretsSet() || + !instagramMgr.isSecretsSet() ) { const secretsFromEnv = { VERIFICATION_ISSUER_DOMAIN: process.env.VERIFICATION_ISSUER_DOMAIN, @@ -93,6 +105,9 @@ const preHandler = (handler, event, context, callback) => { TWITTER_CONSUMER_SECRET: process.env.TWITTER_CONSUMER_SECRET, TWITTER_ACCESS_TOKEN: process.env.TWITTER_ACCESS_TOKEN, TWITTER_ACCESS_TOKEN_SECRET: process.env.TWITTER_ACCESS_TOKEN_SECRET, + INSTAGRAM_CLIENT_ID: process.env.INSTAGRAM_CLIENT_ID, + INSTAGRAM_CLIENT_SECRET: process.env.INSTAGRAM_CLIENT_SECRET, + INSTAGRAM_REDIRECT_URI: process.env.INSTAGRAM_REDIRECT_URI, SEGMENT_WRITE_KEY: process.env.SEGMENT_WRITE_KEY } const config = { ...secretsFromEnv, ...envConfig } @@ -103,6 +118,7 @@ const preHandler = (handler, event, context, callback) => { discordMgr.setSecrets(config) telegramMgr.setSecrets(config) discourseMgr.setSecrets(config) + instagramMgr.setSecrets(config) doHandler(handler, event, context, callback) } else { doHandler(handler, event, context, callback) @@ -202,3 +218,25 @@ let discourseVerifyHandler = new DiscourseVerifyHandler( module.exports.verify_discourse = (event, context, callback) => { preHandler(discourseVerifyHandler, event, context, callback) } + +/// ///////////////////// +// Instagram +/// //////////////////// +let instagramRequestHandler = new InstagramRequestHandler( + instagramMgr, + claimMgr, + analytics +) + +module.exports.request_instagram = (event, context, callback) => { + preHandler(instagramRequestHandler, event, context, callback) +} + +let instagramVerifyHandler = new InstagramVerifyHandler( + instagramMgr, + claimMgr, + analytics +) +module.exports.verify_instagram = (event, context, callback) => { + preHandler(instagramVerifyHandler, event, context, callback) +} diff --git a/packages/server/src/lib/__tests__/instagramMgr.test.js b/packages/server/src/lib/__tests__/instagramMgr.test.js new file mode 100644 index 0000000..dce6c17 --- /dev/null +++ b/packages/server/src/lib/__tests__/instagramMgr.test.js @@ -0,0 +1,253 @@ +const InstagramMgr = require('../instagramMgr') + +describe('InstagramMgr', () => { + let sut + let USERNAME = 'wallkanda' + const CHALLENGE_CODE = '123' + const FAKE_DID = 'did:key:z6MkkyAkqY9bPr8gyQGuJTwQvzk8nsfywHCH4jyM1CgTq4KA' + const USER_ID = '1337' + const FAKE_CODE = '1337' + + beforeAll(() => { + jasmine.DEFAULT_TIMEOUT_INTERVAL = 10000 + sut = new InstagramMgr() + }) + + test('empty constructor', () => { + expect(sut).not.toBeUndefined() + }) + + test('setSecrets', () => { + expect(sut.isSecretsSet()).toEqual(false) + sut.setSecrets({ + REDIS_URL: '123', + REDIS_PASSWORD: 'abc', + INSTAGRAM_CLIENT_ID: '123', + INSTAGRAM_CLIENT_SECRET: 'secret', + INSTAGRAM_REDIRECT_URI: 'https://example.com/auth/' + }) + expect(sut.isSecretsSet()).toEqual(true) + expect(sut.store).not.toBeUndefined() + }) + + test('saveRequest() happy case', done => { + sut.store.write = jest.fn() + sut.store.quit = jest.fn() + sut + .saveRequest(USERNAME, FAKE_DID) + .then(resp => { + expect(/[a-zA-Z0-9]{32}/.test(resp)).toBe(true) + done() + }) + .catch(err => { + fail(err) + done() + }) + }) + + test('generateRedirectionUrl()', done => { + const result = sut.generateRedirectionUrl(1337) + expect(result).toEqual( + 'https://api.instagram.com/oauth/authorize/?client_id=123&redirect_uri=https://example.com/auth/&scope=user_profile&response_type=code&state=1337' + ) + done() + }) + + test('validateProfileFromAccount() no did', done => { + sut + .validateProfileFromAccount(null, CHALLENGE_CODE, FAKE_CODE) + .then(resp => { + fail("shouldn't return") + }) + .catch(err => { + expect(err.message).toEqual('no did provided') + done() + }) + }) + + test('validateProfileFromAccount() no challengeCode', done => { + sut + .validateProfileFromAccount(FAKE_DID, null, FAKE_CODE) + .then(resp => { + fail("shouldn't return") + }) + .catch(err => { + expect(err.message).toEqual('no challengeCode provided') + done() + }) + }) + + test('validateProfileFromAccount() no authorization code', done => { + sut + .validateProfileFromAccount(FAKE_DID, CHALLENGE_CODE, null) + .then(resp => { + fail("shouldn't return") + }) + .catch(err => { + expect(err.message).toEqual('no authorization code provided') + done() + }) + }) + + test('validateProfileFromAccount() database entry not found', done => { + sut.store.quit = jest.fn() + sut.store.read = jest.fn(() => ({})) + sut + .validateProfileFromAccount(FAKE_DID, CHALLENGE_CODE, FAKE_CODE) + .then(resp => { + fail(`shouldn't return`) + }) + .catch(err => { + expect(err.message).toEqual(`No database entry for ${FAKE_DID}`) + done() + }) + }) + + test('validateProfileFromAccount() incorrect challenge code', done => { + sut.store.quit = jest.fn() + sut.store.read = jest.fn(() => ({ + username: USERNAME, + timestamp: Date.now(), + challengeCode: CHALLENGE_CODE, + userId: USER_ID + })) + sut + .validateProfileFromAccount( + FAKE_DID, + 'incorect challenge code', + FAKE_CODE + ) + .then(resp => { + fail(`shouldn't return`) + }) + .catch(err => { + expect(err.message).toEqual('Challenge Code is incorrect') + done() + }) + }) + + test('validateProfileFromAccount() Challenge created over 30min ago', done => { + sut.store.quit = jest.fn() + sut.store.read = jest.fn(() => ({ + username: USERNAME, + timestamp: Date.now() - 31 * 60 * 1000, + challengeCode: CHALLENGE_CODE, + userId: USER_ID + })) + sut + .validateProfileFromAccount(FAKE_DID, CHALLENGE_CODE, FAKE_CODE) + .then(resp => { + fail("shouldn't return") + }) + .catch(err => { + expect(err.message).toEqual( + 'The challenge must have been generated within the last 30 minutes' + ) + done() + }) + }) + + test('validateProfileFromAccount() bad Authorization code', done => { + sut.store.quit = jest.fn() + sut.store.read = jest.fn(() => ({ + username: USERNAME, + timestamp: Date.now(), + challengeCode: CHALLENGE_CODE, + userId: USER_ID + })) + sut.client = jest.fn().mockRejectedValue(new Error('Async error')) + sut + .validateProfileFromAccount(FAKE_DID, CHALLENGE_CODE, FAKE_CODE) + .then(resp => { + fail("shouldn't return") + }) + .catch(err => { + expect(err.message).toEqual( + 'Could not validate user from Instagram. Error: Async error' + ) + done() + }) + }) + + test('validateProfileFromAccount() bad username returned', done => { + sut.store.quit = jest.fn() + sut.store.read = jest.fn(() => ({ + username: USERNAME, + timestamp: Date.now(), + challengeCode: CHALLENGE_CODE, + userId: USER_ID + })) + sut.client = jest + .fn() + .mockImplementationOnce(() => { + return Promise.resolve({ + json: async function () { + return Promise.resolve({ access_token: '123' }) + } + }) + }) + .mockImplementationOnce(() => { + return Promise.resolve({ + json: async function () { + return Promise.resolve({ + username: 'thisisnottheexpectedusername', + id: '123' + }) + } + }) + }) + + sut + .validateProfileFromAccount(FAKE_DID, CHALLENGE_CODE, FAKE_CODE) + .then(resp => { + fail("shouldn't return") + }) + .catch(err => { + expect(err.message).toEqual( + 'Could not validate user from Instagram. Error: Verification made for the wrong username (wallkanda != thisisnottheexpectedusername)' + ) + done() + }) + }) + + test('validateProfileFromAccount()', done => { + sut.store.quit = jest.fn() + sut.store.read = jest.fn(() => ({ + username: USERNAME, + timestamp: Date.now(), + challengeCode: CHALLENGE_CODE, + userId: USER_ID + })) + sut.client = jest + .fn() + .mockImplementationOnce(() => { + return Promise.resolve({ + json: async function () { + return Promise.resolve({ access_token: '123' }) + } + }) + }) + .mockImplementationOnce(() => { + return Promise.resolve({ + json: async function () { + return Promise.resolve({ + username: USERNAME, + id: '123' + }) + } + }) + }) + sut + .validateProfileFromAccount(FAKE_DID, CHALLENGE_CODE, FAKE_CODE) + .then(resp => { + // console.log(resp) + expect(resp.username).toEqual(USERNAME) + expect(resp.id).toEqual('123') + done() + }) + .catch(err => { + fail(err) + done() + }) + }) +}) diff --git a/packages/server/src/lib/analytics.js b/packages/server/src/lib/analytics.js index d61924b..da5ad83 100644 --- a/packages/server/src/lib/analytics.js +++ b/packages/server/src/lib/analytics.js @@ -107,6 +107,20 @@ class Analytics { data.properties = { did_hash: hash(did), status } this._track(data) } + + trackRequestInstagram(did, status) { + let data = {} + data.event = 'request_service_instagram' + data.properties = { did_hash: hash(did), status } + this._track(data) + } + + trackVerifyInstagram(did, status) { + let data = {} + data.event = 'verify_service_instagram' + data.properties = { did_hash: hash(did), status } + this._track(data) + } } module.exports = Analytics diff --git a/packages/server/src/lib/instagramMgr.js b/packages/server/src/lib/instagramMgr.js new file mode 100644 index 0000000..bd7cdcd --- /dev/null +++ b/packages/server/src/lib/instagramMgr.js @@ -0,0 +1,128 @@ +import { randomString } from '@stablelib/random' +import fetch from 'node-fetch' + +const { RedisStore } = require('./store') + +const challengeKey = did => `${did}:instagram` + +class InstagramMgr { + constructor() { + this.client = null + this.client_id = null + this.client_secret = null + this.redirect_uri = null + this.http_redirect = null + this.store = {} + } + + isSecretsSet() { + return ( + this.client_id !== null || + this.client_secret !== null || + this.redirect_uri !== null + ) + } + + setSecrets(secrets) { + this.client = fetch + + this.client_id = secrets.INSTAGRAM_CLIENT_ID + this.client_secret = secrets.INSTAGRAM_CLIENT_SECRET + this.redirect_uri = secrets.INSTAGRAM_REDIRECT_URI + this.http_redirect = secrets.INSTAGRAM_HTTP_REDIRECT + + if (secrets.REDIS_URL) + this.store = new RedisStore({ + host: secrets.REDIS_URL + }) + } + + async saveRequest(username, did) { + const challengeCode = randomString(32) + const data = { + did, + username, + timestamp: Date.now(), + challengeCode + } + try { + await this.store.write(challengeKey(did), data) + // console.log('Saved: ' + data) + } catch (e) { + throw new Error(`issue writing to the database for ${did}. ${e}`) + } + // await this.store.quit() + return challengeCode + } + + // Returns verification url if sucessful + generateRedirectionUrl(challengeCode) { + return `https://api.instagram.com/oauth/authorize/?client_id=${this.client_id}&redirect_uri=${this.redirect_uri}&scope=user_profile&response_type=code&state=${challengeCode}` + } + + async validateProfileFromAccount(did, challengeCode, code) { + if (!did) throw new Error('no did provided') + if (!challengeCode) throw new Error('no challengeCode provided') + if (!code) throw new Error('no authorization code provided') + + let details + try { + details = await this.store.read(challengeKey(did)) + } catch (e) { + throw new Error( + `Error fetching from the database for user ${did}. Error: ${e}` + ) + } + // console.log('Fetched: ' + JSON.stringify(details)) + if (!details || !details.username) + throw new Error(`No database entry for ${did}`) + + // await this.store.quit() + const { username, timestamp, challengeCode: _challengeCode } = details + + if (challengeCode !== _challengeCode) + throw new Error(`Challenge Code is incorrect`) + + const startTime = new Date(timestamp) + if (new Date() - startTime > 30 * 60 * 1000) + throw new Error( + 'The challenge must have been generated within the last 30 minutes' + ) + + // Convert the Instagram code to an Oauth Access token and query /me to verify the user + const params = new URLSearchParams() + params.append('client_id', this.client_id) + params.append('client_secret', this.client_secret) + params.append('grant_type', 'authorization_code') + params.append('redirect_uri', this.redirect_uri) + params.append('code', code) + + try { + const response = await this.client( + 'https://api.instagram.com/oauth/access_token', + { + method: 'post', + body: params + } + ) + const data = await response.json() + + const me = await this.client( + `https://graph.instagram.com/me?fields=id,username,account_type&access_token=${data.access_token}` + ) + const meData = await me.json() + + if (username !== meData.username) { + throw new Error( + `Verification made for the wrong username (${username} != ${meData.username})` + ) + } + + return meData + } catch (e) { + throw new Error(`Could not validate user from Instagram. ${e}`) + } + } +} + +module.exports = InstagramMgr diff --git a/packages/utils/package.json b/packages/utils/package.json index 8ad46ba..64ec765 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -27,6 +27,7 @@ "@babel/core": "^7.12.7", "@babel/preset-env": "^7.12.7", "@stablelib/ed25519": "^1.0.1", + "@stablelib/hex": "^1.0.0", "@stablelib/random": "^1.0.0", "dotenv": "^8.2.0", "eslint": "^7.12.1",