epfl:accounts-oidc

v0.2.0Published 3 months ago

epfl:accounts-oidc Atmosphere package

Connect your Meteor application to one or more identity provider (IdPs) using the modern and popular OpenID-Connect (OIDC) protocol.

Features

¹ It is a bit unfortunate that the Meteor terminology sometimes uses “popup flow” and “redirect flow”, while they are one and the same flow in the OpenID-Connect sense 🤷‍♂️ In this documentation, we use “login style” instead.

Non-features

  • Support for Meteor versions prior to 3
  • “Older” OpenID-Connect or OAuth flows (implicit, hybrid, and so on)
  • Client-side OAuth token redeeming. epfl:accounts-oidc assumes that the server, not the browser, will be fetching the tokens from the IdP at the end of a successful authentication. That is, you should not set “single-page Web app” mode in Entra, in spite of what you believe you know about Meteor and single-page Web apps. This is in contrast to, say, @epfl-si/react-appauth.

Install

In your Meteor v3 project, say

meteor add epfl:accounts-oidc

Configure the Identity Provider

The goal of this step is to obtain the client ID, client secret and OIDC base URL for use below. Consult the documentation of your IdP to find out how to do that.

The OIDC base URL is the one that returns JSON when you paste it into your browser's URL bar, append /.well-known/openid-configuration at the end of it, and press Enter. If your IdP doesn't provide such an auto-configuration JSON document, you will have to use advanced configuration (documented below) to provide each REST endpoint by hand.

For security reasons, many OIDC-compliant IdPs, including Keycloak and Entra, want to know in advance (i.e. whitelist) which URLs the user's browser can be redirected to after logging in. Meteor doesn't let you pick the URL here; as documented, you need to use $ROOT_URL/_oauth/oidc where $ROOT_URL is the root URL of the Web app.

Configure the Meteor app

Do one of the following:

  • in your settings.json:

    1{ // ...
    2  "packages": {
    3    "service-configuration": {
    4      "oidc": {
    5        "loginStyle": "redirect",
    6        "baseUrl": "login.microsoftonline.com/aaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeeeeee/",
    7        "clientId": "CLIENT-ID",
    8        "secret": { "clientSecret": "CLIENT-SECRET" }
    9      }
    10    }
    11  }
    12}
  • OR in some file under server/:

    1import { Meteor } from "meteor/meteor"
    2import { ServiceConfiguration } from "meteor/service-configuration"
    3
    4Meteor.startup(async () => {
    5  await ServiceConfiguration.configurations.upsertAsync(
    6    { service: "oidc" },
    7    {
    8      $set: {
    9        loginStyle: "redirect",
    10        "baseUrl": "https://login.microsoftonline.com/aaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeeeeee/",
    11        "clientId": "CLIENT-ID",
    12        "secret": { "clientSecret": "CLIENT-SECRET" }
    13      },
    14    }
    15  );
    16});

Use in your app

Client-side API

The sole entry point for client code is OIDC.login(); it takes no arguments. (There is no logout function; use Meteor.logout() instead.)

If, for example, you use React and react-meteor-data, your login / logout widget could look like this:

1import React from "react"
2import { Meteor } from "meteor/meteor"
3import { useTracker } from 'meteor/react-meteor-data'
4import { OIDC } from "meteor/epfl:accounts-oidc"
5
6function LoginLogoutClicky () {
7    const isLoggedIn = useTracker(() => !! Meteor.userId());
8
9    return <>
10      { isLoggedIn ?
11          <a href="#" onClick={() => Meteor.logout()}>Logout</a> :
12          <a href="#" onClick={() => OIDC.login()}>Login</a> }
13    </>;
14}

User synchronization

Like all accounts-* Meteor packages, epfl:accounts-oidc can synchronize personal and other information received from the IdP at login time into the Meteor.users collection in MongoDB. A generally useful default behavior is provided out-of-the-box, which the Meteor application may override.

epfl:accounts-oidc directs Meteor to implement the following, default behavior:

  1. Discern whether the user logging in already exists, by searching by .services.oidc.id in MongoDB using the user's email address as the search key.
  2. If the search is unsuccessful (meaning that a new user needs to be created), populate the .profile field of new user's MongoDB document with any and all the information present in either the UserInfo callback results, or the JWT claims. Here, “personal information” means any field mentioned in the relevant section of the OpenID-Connect spec, excluding “technical“ fields i.e. sub and updated_at.
  3. In all cases (new and existing users alike), create or update the .services.oidc structure thusly:
    • .services.oidc.id is set to the email address for new users (dovetailing with step 1), and
    • .services.oidc.claims is set from the decoded JWT claims — All of them this time, including sub, aud and more.

Note that epfl:accounts-oidc does not check the JWKS signature in steps 2 and 3, because it doesn't need to. Read up on that in the API documentation.

You may alter the default behavior described above, by providing your own callback(s) and/or method overrides in server code as described below:

  • Calling Accounts.onCreateuser, lets you override step 2 altogether. More on this below;
  • calling Accounts.setAdditionalFindUserOnExternalLogin, lets you control what happens when step 1 doesn't find the user, even though it should (i.e., a false negative). For instance, your will need to make use of this hook in order to “merge” users between multiple IdPs;
  • and finally, overwriting OIDC.getUserServiceData on the server with your own implementation, lets you change the behavior for steps 1 and 3 at will. See the API documentation for full details.

Regarding Accounts.onCreateuser (the “easiest” of these), the personal information that epfl:accounts-oidc prepared for the default behavior (i.e. without your hook callback), is available as options.profile in that call; meaning that you can get started by writing

1import { CreateUserOptions } from 'meteor/epfl:accounts-oidc'
2
3Accounts.onCreateUser((options : CreateUserOptions, user : any) => {
4  user.profile = options.profile;
5
6  return user;
7});

which will behave exactly as if you didn't use a Accounts.onCreateUser hook at all. To improve from there, you can use the other stuff in options (besides options.profile) that epfl:accounts-oidc prepared for this exact purpose; again, consult the API documentation for the full story.

RBAC example with the groups claim

Assuming your IdP is configured to disclose a groups claim in the JWT token, here is how to securely use it on the server:

1export async function getGroups () {
2  const user = await Meteor.userAsync();
3  return user?.services?.oidc?.claims?.groups || [];
4}
5
6export async function ensureMemberOfGroup (groupName) {
7  if (! (await getGroups()).includes(groupName)) {
8    throw new Meteor::Error("Unauthorized")
9  }
10}

Use await ensureMemberOfGroup("relevant-group"); as an opener in all your server-side publications, methods etc. per the recommendations of the Meteor documentation) to achieve a rudimentary, yet effective form of role-based access control, or RBAC. For more sophisticated use cases, consider using Meteor's built-in roles package.

Multiple Providers

Suppose, for instance, that you want your users to be able to log in using either their GitHub account, or their Google account.

If so, the function newOIDCProvider(slug) should be called on both client and server. It returns an object that works just like OIDC, except that it consumes a separate configuration named after slug. That is, you should re-read “Configure the Meteor app,” above, mentally replacing oidc with the chosen value of slug (i.e. in settings.json or in your upsertAsync call). For instance:

1// imports/authProviders.ts
2
3import { newOIDCProvider } from 'meteor/epfl:accounts-oidc'
4
5export const Google = newOIDCProvider('google');
6export const GitHub = newOIDCProvider('github');
7
1// server/auth.ts
2
3import '../imports/authProviders'
4
5// See above on why (or whether) you want an `onCreateUser` callback:
6Accounts.onCreateUser((options, user : any) => {
7    user.profile = options.profile;
8    if (options.service === "google") {
9        // ...
10    } else if (options.service === "github") {
11        // ...
12    }
13    return user;
14}
1// client/components/multilogin.tsx
2
3import React from "react"
4import { Meteor } from "meteor/meteor"
5import { useTracker } from 'meteor/react-meteor-data'
6import { OIDC } from "meteor/epfl:accounts-oidc"
7import { Google, GitHub } from "../../imports/authProviders"
8
9function LoginLogoutClicky () {
10    const isLoggedIn = useTracker(() => !! Meteor.userId());
11
12    return isLoggedIn ?
13          <div><a href="#" onClick={() => Meteor.logout()}>Logout</a></div> :
14          <>
15              <div><a href="#" onClick={() => Google.login()}>Log in with Google</a></div>
16              <div><a href="#" onClick={() => GitHub.login()}>Log in with GitHub</a></div>
17          </>;
18}
1// settings.json
2{
3    "packages": {
4        "service-configuration": {
5            "google": {
6                // ...
7            },
8            "github": {
9                // ...
10            }
11    }
12}

⚠ As briefly mentioned above, this will cause users from either IdP to live in entirely different namespaces, even if they happen e.g. to have the same email address as disclosed by both IdPs. In many cases, this is not what you want; see § “User synchronization”, above to figure out what you should do about that.

Configuration Reference

Option namePurposeExample value(s)Default
loginStyleChoose the UX for the login operation in the browser"popup" or "redirect""popup"
scopeA list of strings (with IdP-specific meaning) stipulating which personal information to retrieve at login time"openid email" or ["openid", "email"]"openid"
clientIdThe OpenID-Connect Client IDSomethingThatLooksLikeTheCatWalkedOnYourKeyboardN/A
secret.clientSecretThe OpenID-Connect Client Secret. Note that that key is not at the top level like all the others; it is nested inside a secret dict, so as not to be transmitted (published) to the client.More-C@t-Typ1ng,//butWithL33tCh^rsMaybe.ItDependsN/A
loginUrlParametersIdP-specific additional query parameters to pass at login time{"prompt": "consent"}{}
baseUrlThe base URL to resolve OpenID-Connect endpoints fromhttps://login.microsoftonline.com/aaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeeeeee/v2.0N/A
tokenEndpointThe URL of the OIDC Token Endpointhttps://login.microsoftonline.com/aaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeeeeee/oauth2/v2.0/tokentoken_endpoint JSON response field at URL: baseUrl + "/.well-known/openid-configuration"
authorizeEndpointThe URL of the Authorization Endpoint (the one that the server calls to finish the OAuth login process)https://login.microsoftonline.com/aaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeeeeee/oauth2/v2.0/user_infoauthorization_endpoint JSON response field at URL: baseUrl + "/.well-known/openid-configuration"
userinfoEndpointThe URL of the UserInfo Endpointhttps://login.microsoftonline.com/aaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeeeeee/oauth2/v2.0/user_infouserinfo_endpoint JSON response field at URL: baseUrl + "/.well-known/openid-configuration"
popupOptionsAny options to pass to the popup window, if loginStyle === "popup"{ height: 800, width: 600 }{}

See also: OIDCConfiguration in the API docs