Fill your app's profile from NobleID
When a person signs in with NobleID and agrees, your app can fill its own profile from theirs: name, photo, ORCID iD, institution, role and more. It works the same for journals, repositories, lab sites and any other app.
1. Ask for the data
- In Dashboard → OAuth apps, edit your app and tick the scopes you will use. No review is needed. A new app has only OpenID and Profile.
- Ask for the same scopes in your sign-in request. Asking for one your app does not have stops sign-in with
invalid_scope. - Each person sees every piece of data on the consent screen and can untick any of it. Read the
scopein the token response to see what they shared.
| Scope | What it fills |
|---|---|
Profile profile | Name, username, photo, website and research interests |
Email email | Email address, and whether the person confirmed it |
ORCID iD orcid | ORCID iD, when the person proved it by signing in to ORCID |
Affiliations affiliations | Main institution with its ROR iD, and each role and start year |
Works works | The works the person registered on NobleID |
2. Map the claims to your fields
Some claims are only in the userinfo response, not in the ID token. Call https://id.nobleid.org/oauth/userinfo with the access token, and check that its sub is the ID token's.
| Claim | Type | Scope | In | Fills |
|---|---|---|---|---|
sub | string | openid | ID token, userinfo | Account key (not shown)Key the account on it. It never changes. |
nobleid | string | openid | ID token, userinfo | NobleID |
nobleid_uri | URL | openid | ID token, userinfo | Link to NobleIDShow it next to the person's name. |
name | string | profile | ID token, userinfo | Display name |
given_name | string | profile | ID token, userinfo | First name |
family_name | string | profile | ID token, userinfo | Last name |
preferred_username | string | profile | ID token, userinfo | Suggested usernameIt can change. Never key an account on it. |
picture | https URL | profile | ID token, userinfo | PhotoCopy the image if you keep it. The URL changes when the person changes their photo. |
website | http(s) URL | profile | ID token, userinfo | Website |
profile | URL | profile | ID token, userinfo | Link to NobleID profileAbsent when the person's profile is not public. |
research_interests | string[] | profile | Userinfo | Research interests, keywords |
email | string | email | ID token, userinfo | |
email_verified | boolean | email | ID token, userinfo | (check)Link to an existing account by email only when this is true. |
orcid | URL | orcid | ID token, userinfo | ORCID iDhttps://orcid.org/… Sent only when the person proved the iD by signing in to ORCID. |
affiliation | { name, ror? } | affiliations | ID token, userinfo | Institution, affiliationThe main one. ror is an https://ror.org/… URL. Entered by the person, not checked by NobleID. |
affiliations | { organization, role?, start_year?, ror? }[] | affiliations | Userinfo | Position, role, affiliation historyThe main affiliation comes first. start_year is a number. |
works_endpoint | URL | works | Userinfo | Publications listGET it with the access token: titles, ARKs, DOIs and dates. |
- A claim with no value is left out. It is never
nullor an empty string. A missing claim means "not shared or not known", never false. - Text is trimmed. URLs are absolute, and
pictureis always https. - Affiliations are what the person entered. NobleID does not check them. An ORCID iD is sent only after the person proved it.
- NobleID does not send a phone number, postal address, country or bio.
3. Fill only what is empty
Never overwrite what the person edited in your app. Their own edits win over NobleID.
- On the first sign-in, fill the fields that are empty.
- On each later sign-in, read the claims again. Update a field only if it still holds what NobleID sent last time. Keep that last value next to the account to compare.
- Never clear a field because a claim is missing. The person may have unticked that scope.
- Key the account on
sub, never on email, username or name.
Code
Two functions: one turns the claims into a plain profile, the other decides which fields to change.
profile.ts
// After your callback verified the ID token (idToken) and read userinfo.
type Claims = Record<string, unknown>
const text = (v: unknown) => (typeof v === "string" && v.trim() ? v.trim() : undefined)
export function profileFromClaims(idToken: Claims, userinfo: Claims = {}) {
if (userinfo.sub !== undefined && userinfo.sub !== idToken.sub) throw new Error("userinfo is for another person")
const c = { ...idToken, ...userinfo } // userinfo is fresher and has more
const main = (c.affiliation ?? {}) as { name?: string; ror?: string }
const list = Array.isArray(c.affiliations) ? (c.affiliations as { role?: string }[]) : []
return {
sub: String(c.sub),
nobleid: text(c.nobleid),
name: text(c.name),
givenName: text(c.given_name),
familyName: text(c.family_name),
picture: text(c.picture)?.startsWith("https://") ? text(c.picture) : undefined,
website: text(c.website),
researchInterests: Array.isArray(c.research_interests) ? (c.research_interests as string[]) : undefined,
email: text(c.email),
emailVerified: c.email_verified === true,
orcid: c.orcid_verified === true ? text(c.orcid) : undefined,
institution: text(main.name),
institutionRor: text(main.ror),
role: text(list[0]?.role), // the main affiliation comes first
}
}
// Fill a field when it is empty, or when it still holds what NobleID sent
// last time (the person has not edited it here). Never clear a field
// because a claim is missing: the person may have unticked that scope.
export function fillProfile<T extends Record<string, unknown>>(current: T, fresh: Partial<T>, last: Partial<T> = {}) {
const changes: Partial<T> = {}
const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b)
for (const key of Object.keys(fresh) as (keyof T)[]) {
const value = fresh[key]
if (value === undefined) continue
const now = current[key]
const empty = now == null || now === "" || (Array.isArray(now) && now.length === 0)
if (empty || (last[key] !== undefined && same(now, last[key]))) changes[key] = value
}
return changes
}
// On every sign-in:
const p = profileFromClaims(idToken, userinfo)
const fromNobleId = { displayName: p.name, orcid: p.orcid, institution: p.institution, website: p.website }
const changes = fillProfile(user.profile, fromNobleId, user.lastFromNobleId)
await saveProfile(user.id, changes)
await saveLastFromNobleId(user.id, fromNobleId) // compare against it next timeThe sign-in itself (PKCE, the ID token check, userinfo) is in Get started.
On a research platform
Each guide lists the fields that platform fills from NobleID.
- OJSGiven name, family name and email, through Keycloak.
- WordPressNames, nickname and email; website and ORCID iD with a few lines of PHP.
- DSpaceEmail, first name and last name. Nothing else.
- DataverseNames, username and email on the account-creation page.
- MoodleNames, email and photo; the ORCID iD into a custom profile field.
- KeycloakNames and email by default; photo, website, ORCID iD and institution with Attribute Importers.
- JupyterHubNo profile to fill; the claims can reach the spawner through auth_state.
- DiscourseName, username, email and avatar when the account is created.