Skip to content

Commit

Permalink
[DEV-2050] Implement Passport.js Strategy for MyMLH (#1)
Browse files Browse the repository at this point in the history
* Create node.js.yml

* Create npm-publish.yml

* feat: Implement MyMLH OAuth2 strategy

- Add core strategy implementation following Facebook patterns
- Implement uid, info, and data methods from Ruby implementation
- Add comprehensive test suite
- Configure package.json with proper metadata

The implementation includes:
- OAuth2 strategy with MyMLH endpoints
- Support for expandable fields
- Profile normalization
- Comprehensive error handling
- Test coverage for core functionality

* fix: Rename Strategy to MLHStrategy and improve scope handling

- Rename Strategy to MLHStrategy throughout the codebase
- Export MLHStrategy as both default and named export
- Force space separator for MLH scopes
- Add proper scope string normalization
- Update documentation with scope examples

* Fix api auth

* refactor: convert codebase to ES modules
- Added type: module to package.json
- Converted strategy.js to use class syntax and ES module exports
- Updated index.js to use ES module imports/exports
- Updated test file to use ES module imports
- Fixed scope handling and MLHStrategy export issues

* We shouldn't need to rename or map data here

* add license

* Update package-lock.json

* feat: Implement MyMLH OAuth2 strategy

- Add core strategy implementation following Facebook patterns
- Implement uid, info, and data methods from Ruby implementation
- Add comprehensive test suite
- Configure package.json with proper metadata

The implementation includes:
- OAuth2 strategy with MyMLH endpoints
- Support for expandable fields
- Profile normalization
- Comprehensive error handling
- Test coverage for core functionality

* fix: Rename Strategy to MLHStrategy and improve scope handling

- Rename Strategy to MLHStrategy throughout the codebase
- Export MLHStrategy as both default and named export
- Force space separator for MLH scopes
- Add proper scope string normalization
- Update documentation with scope examples

* Fix api auth

* refactor: convert codebase to ES modules
- Added type: module to package.json
- Converted strategy.js to use class syntax and ES module exports
- Updated index.js to use ES module imports/exports
- Updated test file to use ES module imports
- Fixed scope handling and MLHStrategy export issues

* We shouldn't need to rename or map data here

* add license

* Update package-lock.json

* fix: properly normalize user profile in userProfile method
- Add explicit profile object construction
- Include all required profile fields (displayName, name, emails, phoneNumbers)
- Fix failing test for profile normalization

* Revert "fix: properly normalize user profile in userProfile method"

This reverts commit 49f7df2.

* test: update profile test expectations to match raw data format
- Remove normalized field expectations (displayName, name.familyName)
- Add raw field expectations (first_name, last_name)
- Keep strategy functionality unchanged

* Update package.json

* Merge publish workflows

* Rename package

---------

Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Erin Osher <[email protected]>
  • Loading branch information
3 people authored Nov 15, 2024
1 parent c09e96d commit 277e8a2
Show file tree
Hide file tree
Showing 10 changed files with 3,275 additions and 20 deletions.
31 changes: 31 additions & 0 deletions .github/workflows/node.js.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs

name: Node.js CI

on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]

jobs:
build:

runs-on: ubuntu-latest

strategy:
matrix:
node-version: [18.x, 20.x, 22.x]
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/

steps:
- uses: actions/checkout@v4
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- run: npm ci
- run: npm run build --if-present
- run: npm test
36 changes: 36 additions & 0 deletions .github/workflows/npm-publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# This workflow will run tests using node and then publish a package to GitHub Packages when a release is created
# For more information see: https://docs.github.com/en/actions/publishing-packages/publishing-nodejs-packages

name: Node.js Package

on:
release:
types: [created]

jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npm test

publish-npm:
needs: build
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
registry-url: https://registry.npmjs.org/
- run: npm ci
- run: npm publish --provenance --access public
env:
NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}
20 changes: 0 additions & 20 deletions .github/workflows/publish.yml

This file was deleted.

21 changes: 21 additions & 0 deletions LICENSE.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
The MIT License (MIT)

Copyright (c) 2024 Major League Hacking

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
15 changes: 15 additions & 0 deletions lib/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/**
* Module dependencies.
*/
import MLHStrategy from './strategy.js';

/**
* Expose `MLHStrategy` directly from package.
*/
export default MLHStrategy;

/**
* Export constructors.
*/
export { MLHStrategy };
export const Strategy = MLHStrategy;
200 changes: 200 additions & 0 deletions lib/strategy.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
/**
* Module dependencies.
*/
import OAuth2Strategy from 'passport-oauth2';

/**
* `MLHStrategy` constructor.
*
* The MyMLH authentication strategy authenticates requests by delegating to
* MyMLH using the OAuth 2.0 protocol.
*
* Applications must supply a `verify` callback which accepts an `accessToken`,
* `refreshToken` and service-specific `profile`, and then calls the `cb`
* callback supplying a `user`, which should be set to `false` if the
* credentials are not valid. If an exception occurred, `err` should be set.
*
* Options:
* - `clientID` your MyMLH application's client id
* - `clientSecret` your MyMLH application's client secret
* - `callbackURL` URL to which MyMLH will redirect the user after granting authorization
* - `expandFields` optional array of fields to expand in the user profile
* - `scope` space-separated list of permissions (e.g., 'public offline_access user:read:profile')
*
* Examples:
*
* passport.use(new MLHStrategy({
* clientID: '123-456-789',
* clientSecret: 'shhh-its-a-secret'
* callbackURL: 'https://www.example.net/auth/mlh/callback',
* expandFields: ['education', 'professional_experience']
* },
* function(accessToken, refreshToken, profile, cb) {
* User.findOrCreate(..., function (err, user) {
* cb(err, user);
* });
* }
* ));
*
* @constructor
* @param {object} options
* @param {function} verify
* @access public
*/
class MLHStrategy extends OAuth2Strategy {
constructor(options, verify) {
options = options || {};
options.authorizationURL = options.authorizationURL || 'https://my.mlh.io/oauth/authorize';
options.tokenURL = options.tokenURL || 'https://my.mlh.io/oauth/token';
options.scopeSeparator = ' '; // Force space separator for MLH scopes
options.customHeaders = options.customHeaders || {};
options.authScheme = 'request-body'; // Match Ruby implementation

if (!options.customHeaders['User-Agent']) {
options.customHeaders['User-Agent'] = options.userAgent || 'passport-mlh-oauth2';
}

// Ensure scope is properly formatted
if (options.scope && typeof options.scope === 'string') {
// Split on any whitespace and rejoin with single spaces
options.scope = options.scope.split(/\s+/).join(' ');
}

super(options, verify);
this.name = 'mlh';
this._options = options;
this._profileURL = options.profileURL || 'https://api.mlh.com/v4/users/me';
this._oauth2.useAuthorizationHeaderforGET(true);
}

/**
* Retrieve user profile from MyMLH.
*
* This function constructs a normalized profile, with the following properties:
*
* - `provider` always set to `mlh`
* - `id` the user's MyMLH ID
* - `displayName` the user's full name
* - `name.familyName` the user's last name
* - `name.givenName` the user's first name
* - `emails` the user's email addresses
* - `phoneNumbers` the user's phone numbers
*
* @param {string} accessToken
* @param {function} done
* @access protected
*/
userProfile(accessToken, done) {
this._accessToken = accessToken;

this.data((err, data) => {
if (err) { return done(err); }

try {
const profile = {
provider: 'mlh',
...data
};

done(null, profile);
} catch (e) {
done(e);
}
});
}

/**
* Return the MyMLH user ID.
*
* @return {String}
* @api protected
*/
uid() {
return this._data.id;
}

/**
* Retrieve and process user data from MyMLH.
*
* @param {function} done
* @api protected
*/
data(done) {
if (this._data) {
return done(null, this._data);
}

const url = this._buildApiUrl();

this._oauth2.get(url, this._accessToken, (err, body) => {
if (err) { return done(null, {}); }

try {
const json = JSON.parse(body);
this._data = this._processData(json);
done(null, this._data);
} catch (e) {
done(null, {});
}
});
}

/**
* Build the MyMLH API URL with optional expand fields.
*
* @return {String}
* @api private
*/
_buildApiUrl() {
const url = this._profileURL;
const expandFields = this._options.expandFields || [];

if (!expandFields.length) return url;

const expandQuery = expandFields
.map(field => `expand[]=${encodeURIComponent(field)}`)
.join('&');

return `${url}?${expandQuery}`;
}

/**
* Process and normalize the MyMLH API response data.
*
* @param {Object} data
* @return {Object}
* @api private
*/
_processData(data) {
if (!data || typeof data !== 'object') return {};
return this._symbolizeNestedArrays(data);
}

/**
* Deep transform object keys and handle nested arrays.
*
* @param {Object} obj
* @return {Object}
* @api private
*/
_symbolizeNestedArrays(obj) {
if (Array.isArray(obj)) {
return obj.map(item => this._symbolizeNestedArrays(item));
}

if (obj && typeof obj === 'object') {
const result = {};
for (const [key, value] of Object.entries(obj)) {
result[key] = this._symbolizeNestedArrays(value);
}
return result;
}

return obj;
}
}

/**
* Expose `MLHStrategy`.
*/
export default MLHStrategy;
Loading

0 comments on commit 277e8a2

Please sign in to comment.