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
- Fully compatible with the Meteor accounts API
- Supports the OIDC “Authorization Code Flow”, in both “popup” and “redirect” flavors¹
¹ 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-oidcassumes 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:
- Discern whether the user logging in already exists, by searching by
.services.oidc.idin MongoDB using the user's email address as the search key. - If the search is unsuccessful (meaning that a new user needs to be created), populate the
.profilefield of new user's MongoDB document with any and all the information present in either theUserInfocallback 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.subandupdated_at. - In all cases (new and existing users alike), create or update the
.services.oidcstructure thusly:.services.oidc.idis set to the email address for new users (dovetailing with step 1), and.services.oidc.claimsis set from the decoded JWT claims — All of them this time, includingsub,audand 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.getUserServiceDataon 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 name | Purpose | Example value(s) | Default |
|---|---|---|---|
loginStyle | Choose the UX for the login operation in the browser | "popup" or "redirect" | "popup" |
scope | A list of strings (with IdP-specific meaning) stipulating which personal information to retrieve at login time | "openid email" or ["openid", "email"] | "openid" |
clientId | The OpenID-Connect Client ID | SomethingThatLooksLikeTheCatWalkedOnYourKeyboard | N/A |
secret.clientSecret | The 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.ItDepends | N/A |
loginUrlParameters | IdP-specific additional query parameters to pass at login time | {"prompt": "consent"} | {} |
baseUrl | The base URL to resolve OpenID-Connect endpoints from | https://login.microsoftonline.com/aaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeeeeee/v2.0 | N/A |
tokenEndpoint | The URL of the OIDC Token Endpoint | https://login.microsoftonline.com/aaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeeeeee/oauth2/v2.0/token | token_endpoint JSON response field at URL: baseUrl + "/.well-known/openid-configuration" |
authorizeEndpoint | The 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_info | authorization_endpoint JSON response field at URL: baseUrl + "/.well-known/openid-configuration" |
userinfoEndpoint | The URL of the UserInfo Endpoint | https://login.microsoftonline.com/aaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeeeeee/oauth2/v2.0/user_info | userinfo_endpoint JSON response field at URL: baseUrl + "/.well-known/openid-configuration" |
popupOptions | Any options to pass to the popup window, if loginStyle === "popup" | { height: 800, width: 600 } | {} |
See also: OIDCConfiguration in the API docs