diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..82a9a73 --- /dev/null +++ b/.eslintignore @@ -0,0 +1 @@ +.eslintrc.js \ No newline at end of file diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000..cf529c0 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,20 @@ +module.exports = { + root: true, + env: { + browser: true, + es2021: true, + node: true, + }, + extends: ['plugin:@hapi/module'], + parser: '@typescript-eslint/parser', + parserOptions: { + ecmaVersion: 2021, + sourceType: 'module', + }, + plugins: ['@typescript-eslint'], + rules: { + '@typescript-eslint/no-explicit-any': 0, + '@hapi/scope-start': 0, + 'strict': 0, + }, +}; diff --git a/lib/index.d.ts b/lib/index.d.ts new file mode 100644 index 0000000..065ca2a --- /dev/null +++ b/lib/index.d.ts @@ -0,0 +1,305 @@ +import { AuthCredentials, Plugin, Request } from '@hapi/hapi'; + +declare module '@hapi/hapi' { + interface ServerAuth { + strategy(name: string, scheme: 'bell', options: BellOptions): void; + } +} + +export interface StringLikeMap { + [key: string]: string | number; +} +export type Provider = + | 'arcgisonline' + | 'auth0' + | 'azure' + | 'bitbucket' + | 'cognito' + | 'digitalocean' + | 'discord' + | 'dropbox' + | 'facebook' + | 'fitbit' + | 'foursquare' + | 'github' + | 'gitlab' + | 'google' + | 'googleplus' + | 'instagram' + | 'linkedin' + | 'live' + | 'medium' + | 'meetup' + | 'mixer' + | 'nest' + | 'okta' + | 'phabricator' + | 'pingfed' + | 'pinterest' + | 'reddit' + | 'salesforce' + | 'slack' + | 'spotify' + | 'stripe' + | 'trakt' + | 'tumblr' + | 'twitch' + | 'twitter' + | 'vk' + | 'wordpress' + | 'yahoo'; + +export type RequestPassThrough = (request: Request) => PromiseLike | AuthCredentials; + +export interface OptionalOptions { + /** + * the name of the cookie used to manage the temporary state. + * Defaults to 'bell-provider' where 'provider' is the provider name (or 'custom' for custom providers). + * For example, the Twitter cookie name defaults to 'bell-twitter'. + */ + cookie?: string | undefined; + /** + * sets the cookie secure flag. + * Defaults to true. + */ + isSecure?: boolean | undefined; + /** + * sets the cookie HTTP only flag. + * Defaults to true. + */ + isHttpOnly?: boolean | undefined; + /** + * cookie time-to-live in milliseconds. + * Defaults to null (session time-life - cookies are deleted when the browser is closed). + */ + ttl?: number | undefined; + /** + * the domain scope. + * Defaults to null (no domain). + */ + domain?: string | undefined; + /** + * provider-specific query parameters for the authentication endpoint. + * It may be passed either as an object to merge into the query string, + * or a function which takes the client's request and returns an object. + * Each provider supports its own set of parameters which customize the user's login experience. + * For example: + * * Facebook supports `display` ('page', 'popup', or 'touch'), `auth_type`, `auth_nonce`. + * * Google supports `access_type`, `approval_prompt`, `prompt`, `login_hint`, `user_id`, `hd`. + * * Twitter supports `force_login`, `screen_name`. + * * Linkedin supports `fields`. + */ + providerParams?: StringLikeMap | ((request: Request) => StringLikeMap) | undefined; + /** + * allows passing query parameters from a bell protected endpoint to the auth request. + * It will merge the query params you pass along with the providerParams and any other predefined ones. + * Be aware that this will override predefined query parameters! + * Default to false. + */ + allowRuntimeProviderParams?: StringLikeMap | boolean | undefined; + /** + * Each built-in vendor comes with the required scope for basic profile information. + * Use scope to specify a different scope as required by your application. + * It may be passed either as an object to merge into the query string, + * or a function which takes the client's request and returns an object. + * Consult the provider for their specific supported scopes. + */ + scope?: string[] | ((request: Request) => string[]) | undefined; + /** + * skips obtaining a user profile from the provider. + * Useful if you need specific scopes, + * but not the user profile. + * Defaults to false. + */ + skipProfile?: boolean | undefined; + /** + * a configuration object used to customize the provider settings. + * The built-in 'twitter' provider accepts the `extendedProfile` & `getMethod` options. + * option which allows disabling the extra profile request when the provider + * returns the user information in the callback (defaults to true). + * The built-in 'github' and 'phabricator' providers accept the uri + * option which allows pointing to a private enterprise installation (e.g. 'https://vpn.example.com'). + * See Providers documentation for more information. + */ + config?: + | { extendedProfile?: boolean | undefined; getMethod?: string | undefined } + | { uri?: string | undefined } + | undefined; + /** + * an object of key-value pairs that specify additional + * URL query parameters to send with the profile request to the provider. + * The built-in facebook provider, + * for example, could have fields specified to determine the fields returned from the user's graph, + * which would then be available to you in the auth.credentials.profile.raw object. + */ + profileParams?: StringLikeMap | undefined; + /** + * allows passing additional OAuth state from initial request. + * This must be a function returning a string, + * which will be appended to the bell internal state parameter for OAuth code flow. + */ + runtimeStateCallback?(req: Request): string; + + // THESE ARE IN THE *REQUIRED* section but are actually not... + /** + * A boolean indicating whether or not you want the redirect_uri to be forced to https. + * Useful if your hapi application runs as http, but is accessed through https. + */ + forceHttps?: boolean | undefined; + /** + * Set the base redirect_uri manually if it cannot be inferred properly from server settings. + * Useful to override port, protocol, and host if proxied or forwarded. + */ + location?: string | ((req: Request) => string) | undefined; +} + +export interface RequiredProviderOptions { + /** + * the cookie encryption password. + * Used to encrypt the temporary state cookie used by the module in + * between the authorization protocol steps. + */ + password: string; + /** + * the OAuth client identifier (consumer key). + */ + clientId: string; + /** + * the OAuth client secret (consumer secret) + */ + clientSecret: string; +} + +export interface KnownProviderOptions extends RequiredProviderOptions, OptionalOptions { + provider: Provider; +} + +/** + * @param uri the requested resource URI (bell will add the token or authentication header as needed). + * @param params any URI query parameters (cannot include them in the URI due to signature requirements). + */ +export type AuthedRequest = (uri: string, params?: { [key: string]: string }) => Promise; + +export interface Credentials { + provider: Provider | 'custom'; + token: string; + query: StringLikeMap; + /** + * Varying data depending on provider. + */ + profile?: object | undefined; +} + +export interface Credentials1 extends Credentials { + secret: string; +} + +export interface Credentials2 extends Credentials { + refreshToken?: string | undefined; + expiresIn?: number | undefined; +} + +export interface CustomProtocol { + /** + * The name of the protocol. + * @default custom + */ + name?: string | undefined; + /** + * the authorization endpoint URI. + */ + auth: string; + /** + * the access token endpoint URI. + */ + token: string; + /** + * a headers object with additional headers required by the provider + * (e.g. GitHub required the 'User-Agent' header which is set by default). + */ + headers?: { + [key: string]: string; + } | undefined; +} + +/** + * a function used to obtain user profile information and normalize it. + * @param credentials the credentials object. + * Change the object directly within the function (profile information is typically stored under credentials.profile). + * @param params the parsed information received from the provider (e.g. token, secret, and other custom fields). + * @param get an OAuth helper function to make authenticated requests using the credentials received. + */ +export type ProfileGetter = ( + this: CustomProviderOptions, + credentials: C, + params: { [key: string]: string }, + get: AuthedRequest, +) => Promise; + +export interface CustomProtocol1 extends CustomProtocol { + /** + * the authorization protocol used. + */ + protocol: 'oauth'; + + /** + * the OAuth signature method. Must be one of: + * * 'HMAC-SHA1' - default + * * 'RSA-SHA1' - in that case, the clientSecret is your RSA private key + */ + signatureMethod?: 'HMAC-SHA1' | 'RSA-SHA1' | undefined; + /** + * the temporary credentials (request token) endpoint). + */ + temporary?: string | undefined; + + profile: ProfileGetter; +} + +export type PkceSetting = 'plain' | 'S256'; + +export interface CustomProtocol2 extends CustomProtocol { + /** + * the authorization protocol used. + */ + protocol: 'oauth2'; + /** + * an array of scope strings. + */ + scope?: string[] | ((query: StringLikeMap) => string[]) | undefined; + /** + * boolean that determines if OAuth client id and client secret will be sent + * as parameters as opposed to an Authorization header. + * Defaults to false. + */ + useParamsAuth?: boolean | undefined; + + /** + * If specified, uses proof key exchange. + */ + pkce?: PkceSetting | undefined; + + /** + * the scope separator character. Only required when a provider has a broken OAuth 2.0 implementation. Defaults to space (Facebook and GitHub default to comma). + */ + scopeSeparator?: string | undefined; + + profile: ProfileGetter; +} + +export interface CustomProviderOptions extends RequiredProviderOptions, OptionalOptions { + provider: CustomProtocol1 | CustomProtocol2; +} + +export type BellOptions = CustomProviderOptions | KnownProviderOptions; + +export const plugin: Plugin; +/** + * Enables simulation mode. + */ +export function simulate(credentialsFunc: RequestPassThrough): void; +/** + * [See docs](https://github.com/hapijs/bell/blob/master/API.md#simulated-authentication) + * Disables simulation mode + */ +export function simulate(state: false): void; \ No newline at end of file diff --git a/package.json b/package.json index 85655a1..0818950 100644 --- a/package.json +++ b/package.json @@ -62,7 +62,10 @@ "@hapi/hapi": "^21.2.1", "@hapi/hawk": "^8.0.0", "@hapi/lab": "^25.0.1", - "@hapi/teamwork": "^6.0.0" + "@hapi/teamwork": "^6.0.0", + "@typescript-eslint/eslint-plugin": "^7.3.1", + "@typescript-eslint/parser": "^7.3.1", + "typescript": "^5.4.2" }, "scripts": { "test": "lab -a @hapi/code -t 100 -L -m 3000", diff --git a/test/types/index.ts b/test/types/index.ts new file mode 100644 index 0000000..de7e765 --- /dev/null +++ b/test/types/index.ts @@ -0,0 +1,143 @@ +import lab from '@hapi/lab'; + +import * as Bell from '../..'; +import { Server } from '@hapi/hapi'; + +async function run() { + const server = new Server({ port: 8000 }); + await server.register(Bell); + + + + Bell.simulate(async () => ({})); + Bell.simulate(() => ({})); + + server.auth.strategy('arcgisonline', 'bell', { + provider: 'twitter', + password: 'some cookie password', + location: 'http://example.com/oauth', + clientId: '', + clientSecret: '', + scope(request) { + const scopes = ['public_profile', 'email']; + if (request.query.wantsSharePermission) { + scopes.push('publish_actions'); + } + return scopes; + }, + }); + + + const providers: Bell.BellOptions[] = [{ + provider: 'discord', + password: 'cookie_encryption_password_secure', + isSecure: false, + clientId: '', + clientSecret: '', + location: () => '', + }, { + provider: 'facebook', + password: 'cookie_encryption_password_secure', + isSecure: false, + clientId: '', + clientSecret: '', + location: server.info.uri, + }, { + provider: 'google', + password: 'cookie_encryption_password_secure', + isSecure: false, + clientId: '', + clientSecret: '', + location: server.info.uri, + }, { + provider: 'linkedin', + password: 'cookie_encryption_password_secure', + isSecure: false, + clientId: '', + clientSecret: '', + providerParams: { + redirect_uri: server.info.uri + '/bell/door', + }, + }, { + provider: 'meetup', + password: 'cookie_encryption_password_secure', + isSecure: false, + clientId: '', + clientSecret: '', + scope: ['basic', 'ageless', 'group_edit', 'reporting'], + }, { + provider: 'nest', + password: 'cookie_encryption_password_secure', + isSecure: false, + clientId: '', + clientSecret: '', + }, { + provider: 'okta', + config: { uri: 'https://your-organization.okta.com' }, + password: 'cookie_encryption_password_secure', + isSecure: false, + location: 'http://127.0.0.1:8000', + clientId: '', + clientSecret: '', + }, { + provider: 'slack', + password: 'cookie_encryption_password_secure', + isSecure: false, + clientId: '', + clientSecret: '', + }, { + provider: 'twitch', + password: 'cookie_encryption_password_secure', + isSecure: false, + clientId: '', + clientSecret: '', + scope: ['user_read', 'channel_read'], + }, { + provider: { + auth: 'http://test.com/auth', + token: 'http://test.com/auth', + name: 'custom', + protocol: 'oauth', + temporary: 'wat', + async profile(credentials, params, get) { + console.log(credentials.provider); + console.log(credentials.query); + console.log(credentials.secret); + console.log(this.clientId); + credentials.profile = await get('http://test.com/profile', { + a: 'test', + }); + }, + }, + password: 'cookie_encryption_password_secure', + isSecure: false, + clientId: '', + clientSecret: '', + location: () => '', + }, { + provider: { + auth: 'http://test.com/auth', + token: 'http://test.com/auth', + name: 'custom', + protocol: 'oauth2', + scope: ['a', 's', 'd', 'f'], + scopeSeparator: '~~~', + pkce: 'S256', + async profile(credentials, params, get) { + console.log(credentials.provider); + console.log(credentials.query); + console.log(credentials.token); + console.log(credentials.refreshToken); + console.log(this.clientId); + credentials.profile = await get('http://test.com/profile', { + a: 'test', + }); + }, + }, + password: 'cookie_encryption_password_secure', + isSecure: false, + clientId: '', + clientSecret: '', + location: () => '', + }]; +} \ No newline at end of file