Blog

How LinkedIn Import Phase 1 is built

Resumer can pull LinkedIn into a draft you review first — your CV stays on your device, not our servers. Feature flag off in prod until we’re ready.

This post is the implementation behind that sentence. It describes the code on main after PR #13 (feat: LinkedIn OAuth + paste import (Phase 1)), merged 30 Sep 2026 at 21:16 CEST as 7cb6ca943013b0c41e2c95889ba04096c175d95f. The LinkedIn paths were not touched again after the PR head ef42961.

What Phase 1 actually is

Phase 1 is a broker plus a review dialog. It is not a LinkedIn profile import.

What it does:

  1. A button on /build starts Sign In with LinkedIn using OpenID Connect.
  2. Three App Router route handlers exchange the code, read userinfo, and hand a short-lived identity back to the browser.
  3. The browser opens a review dialog. You can edit name, email, a LinkedIn URL, and optional pasted jobs and education, then apply that draft to the form or throw it away.
  4. The form uses defaultValues and reset() so that apply actually sticks.

What it does not do:

  • It does not read jobs or education from LinkedIn. OIDC userinfo is name, and email if the member allowed it. A profile URL and a picture are mapped only when userinfo actually contains them. The picture is then ignored, because the editor has no photo field.
  • It does not store a CV. There is no database write on this path, and redeem refuses a request body.
  • It is not on in production. The flag defaults off. Production leaves NEXT_PUBLIC_FEATURE_LINKEDIN_IMPORT unset, so the button is absent and the routes answer 404.

Phases 2–4 (a localStorage workspace and variants, job-description rewrite, ATS checks) are not in this merge.

The app is Next.js 15 App Router (next ^15.5.12 in package.json). /build is a client page. The LinkedIn endpoints are route handlers, not server actions. Nothing in this feature uploads a file. The handlers opt out of static rendering and send Cache-Control: no-store.

Request flow

src/app/build/page.tsx is a client component. It renders Edit, which renders Form. When the flag is on, Form mounts LinkedInImport.

Clicking Import from LinkedIn writes the current react-hook-form values to sessionStorage under resumer-editor-snapshot, then does a full navigation:

// src/components/forms/LinkedInImport.tsx
sessionStorage.setItem(EDITOR_SNAPSHOT_KEY, JSON.stringify(getValues()));
window.location.assign("/api/linkedin/authorize");

That navigation is why the snapshot exists. The editor unmounts. Without the snapshot, coming back would be an empty form.

1. Authorize

GET /api/linkedin/authorize is a route handler. It checks the flag, then asks authorizeResult() for either a redirect or a JSON error.

// src/app/api/linkedin/authorize/route.ts
export const runtime = "nodejs";
export const dynamic = "force-dynamic";

export function GET() {
  if (!isLinkedInImportEnabled()) {
    return featureDisabledResponse();
  }
  return toNextResponse(authorizeResult());
}

runtime = "nodejs" is required: the seal uses node:crypto, not an Edge API. dynamic = "force-dynamic" keeps the handler off the static path, because the response depends on env and sets a cookie.

If the four server secrets are present, buildAuthorizeUrl sends the browser to LinkedIn (default https://www.linkedin.com/oauth/v2/authorization) with response_type=code and a fixed scope:

// src/lib/linkedin/server/flow.ts
url.searchParams.set("response_type", "code");
url.searchParams.set("client_id", config.clientId);
url.searchParams.set("redirect_uri", config.redirectUri);
url.searchParams.set("state", state);
url.searchParams.set("scope", "openid profile email");

The same response sets an httpOnly cookie, linkedin_oauth_state, to 32 random bytes (base64url). Max age is STATE_MAX_AGE_SECONDS (10 minutes). toNextResponse always sets httpOnly, sameSite: "lax", path: "/", and secure only when NODE_ENV === "production". It also sets Cache-Control: no-store.

If config is missing or a URL is not http(s), authorize returns 503 linkedin_not_configured and does not redirect.

2. Callback

LinkedIn redirects to GET /api/linkedin/callback. The handler passes the query string, the state cookie, and request.nextUrl.origin into completeCallback.

Checks, in order:

  • Flag off → 404 feature_disabled (the handler returns before completeCallback).
  • Config missing → 503, and the state cookie is cleared.
  • error=user_cancelled_login or user_cancelled_authorize → redirect to /build?linkedin=cancelled.
  • Any other OAuth error → /build?linkedin=error&reason=oauth_denied.
  • Missing code or state, or lengths over 512 / 256 → reason=missing_params.
  • State cookie missing or not equal to the query state → reason=state_mismatch. Comparison is timingSafeEqual on equal-length buffers (safeEqual).

On a match, the server POSTs application/x-www-form-urlencoded to the token URL (default https://www.linkedin.com/oauth/v2/accessToken) with grant_type=authorization_code, the code, redirect URI, client id, and client secret. Timeout is AbortSignal.timeout(10000). It reads access_token and nothing else from that JSON.

It then GETs userinfo (default https://api.linkedin.com/v2/userinfo) with Authorization: Bearer. mapUserInfo keeps an allowlist: sub, name (or given + family), optional given_name, family_name, email, email_verified, an http(s) picture, locale, and profile or profile_url. Anything else, including jobs and the token, is dropped. sub and name are required; otherwise the reason is identity_incomplete.

The access token is not written anywhere. The identity is sealed into a second cookie, linkedin_handoff, the state cookie is cleared, and the browser is sent to /build?linkedin=ready.

3. Redeem

Back on /build, LinkedInImport strips linkedin and reason from the URL with history.replaceState, restores the editor snapshot if it parses, then POSTs to redeem with no body:

// src/components/forms/LinkedInImport.tsx
const response = await fetch("/api/linkedin/redeem", {
  method: "POST",
  credentials: "same-origin",
  cache: "no-store",
  headers: { Accept: "application/json" },
});

credentials: "same-origin" is what sends the handoff cookie. cache: "no-store" matches the response header. A module-level redeemTask promise means a remount (React Strict Mode) does not redeem twice. The cookie is cleared on the first successful response, so a second POST would 410.

redeemResult refuses any non-empty body before it opens the cookie:

// src/lib/linkedin/server/flow.ts
if (input.bodyText.trim()) {
  return {
    type: "json",
    status: 400,
    body: {
      error: "unexpected_body",
      message: "This endpoint does not accept a resume or profile body.",
    },
    cookies: [],
  };
}

A missing, tampered, or expired handoff is 410 linkedin_handoff_missing, and the cookie is cleared. Success is 200 { identity } and the cookie is cleared in the same response. That is the whole server store: two cookies, both gone by the time the dialog is on screen. There is no server-side nonce table. "Already used" means the cookie was wiped.

4. Review on /build

A valid identity becomes an ImportDraft via identityToDraft and opens the Review LinkedIn import dialog. The user can edit fields, merge a paste, add or remove rows, then Add to editor or Cancel.

Add calls onApply, which is reset(toFormValues(draft, getValues())). Cancel only closes the dialog. The snapshot was already restored on mount, so cancel leaves that restored editor and shows "The editor was not changed." Confirm does not POST the draft anywhere. The success copy in the client says the resume was not sent to the server.

Where the modules live

Piece Path Runs
Flag src/lib/linkedin/feature.ts Server routes and the client bundle. Direct process.env.NEXT_PUBLIC_* so Next can inline it.
Types and allowlist src/lib/linkedin/types.ts LinkedInIdentity, ImportDraft, pickLinkedInIdentity.
Userinfo map src/lib/linkedin/server/userinfo.ts Server only.
Config and cookie names src/lib/linkedin/server/config.ts Server only.
Seal / open src/lib/linkedin/server/crypto.ts Server only. node:crypto.
Authorize, callback, redeem src/lib/linkedin/server/flow.ts Server only. Route handlers are thin wrappers.
HTTP shape src/lib/linkedin/server/respond.ts Cookies, 404 body, Cache-Control.
Identity → draft, paste merge src/lib/linkedin/draft.ts Client, after redeem.
Paste parser src/lib/linkedin/paste.ts Client. No network.
Draft → form values, snapshot parse src/lib/linkedin/form-values.ts Client. Key resumer-editor-snapshot.
Dialog src/components/forms/LinkedInImport.tsx Client, only rendered when the flag is on.
Apply src/components/forms/Form.tsx Client. useForm + reset.

src/app/api/resume/route.ts is unrelated: its GET returns an empty JSON string. It is not a CV store and this feature does not call it.

Local proof files sit next to the app and are not npm scripts (package.json scripts are dev, build, start, lint): scripts/prove-linkedin-phase1.mjs, scripts/mock-linkedin-oidc.mjs, scripts/fixtures/linkedin-userinfo.json, scripts/fixtures/linkedin-paste.txt. The broker uses fetch and node:crypto. There is no LinkedIn SDK in package.json.

Flag, secrets, and the 404

The flag is an exact string compare. Unset, empty, "1", "TRUE", and "true " are all off.

// src/lib/linkedin/feature.ts
export function isLinkedInImportEnabled(env = process.env): boolean {
  // Direct `process.env.NEXT_PUBLIC_*` so Next inlines the flag in the client.
  // Bracket access keeps a passed-in env object exact ("true" or off).
  const flag =
    env === process.env
      ? process.env.NEXT_PUBLIC_FEATURE_LINKEDIN_IMPORT
      : env["NEXT_PUBLIC_FEATURE_LINKEDIN_IMPORT"];
  return flag === "true";
}

The comment in that file is the product split: the check gates resume import UI and the import OAuth routes. The broker modules stay in the repo for a possible later Sign In with LinkedIn. With the flag off, Form renders null instead of LinkedInImport, so the button is not in the tree.

Every LinkedIn route calls the same helper before doing work:

// src/lib/linkedin/server/respond.ts
export function featureDisabledResponse(): NextResponse {
  return toNextResponse({
    type: "json",
    status: 404,
    body: {
      error: "feature_disabled",
      message: "LinkedIn resume import is disabled.",
    },
    cookies: [],
  });
}

That is a JSON 404 from the route handler, not notFound() and not a redirect to LinkedIn. A scanner that hits /api/linkedin/authorize with the flag off gets the JSON body and no Location header.

When the flag is on, these four must be non-empty or the handler returns 503:

  • LINKEDIN_CLIENT_ID
  • LINKEDIN_CLIENT_SECRET
  • LINKEDIN_REDIRECT_URI (must be an http(s) URL, and must match the LinkedIn app)
  • LINKEDIN_SIGNING_SECRET (used only to seal the handoff; it is not sent to LinkedIn)

Three optional overrides exist for tests, and fall back to LinkedIn's hosts: LINKEDIN_AUTHORIZE_URL, LINKEDIN_TOKEN_URL, LINKEDIN_USERINFO_URL. They are not required in production. The LinkedIn app product is Sign In with LinkedIn using OpenID Connect, scopes openid profile email.

Production leaves the public flag unset, so the 503 branch and the secrets are unused there. The routes still ship. They 404.

What the server holds vs what stays in the browser

The handoff cookie is the only copy of the identity, and it lives on the browser. The server can open it because it has LINKEDIN_SIGNING_SECRET.

// src/lib/linkedin/server/crypto.ts
function keyFromSecret(secret: string): Buffer {
  return createHash("sha256").update(secret, "utf8").digest();
}

export function sealJson(value: unknown, secret: string): string {
  const iv = randomBytes(12);
  const cipher = createCipheriv("aes-256-gcm", keyFromSecret(secret), iv);
  const ciphertext = Buffer.concat([
    cipher.update(JSON.stringify(value), "utf8"),
    cipher.final(),
  ]);
  const tag = cipher.getAuthTag();
  return [
    "v1",
    iv.toString("base64url"),
    tag.toString("base64url"),
    ciphertext.toString("base64url"),
  ].join(".");
}

sealHandoff wraps that as { v: 1, exp, identity } where identity has already been through pickLinkedInIdentity. exp is now plus HANDOFF_TTL_MS. The cookie max-age is HANDOFF_MAX_AGE_SECONDS (5 minutes). openHandoff rejects a bad version, a bad tag, a non-identity payload, or exp <= now.

So the server, for this feature, holds:

  • The OAuth client secret and the signing secret, in env.
  • The authorization code and access token, in memory, for one callback request. They are not cookies and not logged by this code.
  • Nothing after the response is sent.

The browser holds:

  • sessionStorage["resumer-editor-snapshot"] from the click until the return effect reads it and removeItems it. parseEditorSnapshot checks the shape (personal details, contact details, experience and education arrays) and returns null if it does not parse. A failed parse does not restore.
  • The review dialog state: the draft, the paste textarea, and any edits. Paste never leaves the page. Merge calls mergePasteIntoDraft locally.
  • The resume, as react-hook-form state, after confirm.

Cookie flags are httpOnly, so page JavaScript cannot read the handoff. Redeem is the only way to see the identity, and it returns the allowlisted object once.

The OIDC limit, and the paste path

identityToDraft always starts from that thin identity. The first warning is a constant, not a guess:

// src/lib/linkedin/draft.ts
export const LINKEDIN_OIDC_HISTORY_WARNING =
  "Sign In with LinkedIn does not include jobs or education. Self-serve LinkedIn only returns basic identity (name, and email if you allowed it). Experience and Education stay empty unless you paste them below and review the result.";

The draft source is "linkedin_oidc". Fields are personal_details.name and contact_details with email and, only if userinfo had one, linkedin set from profileUrl. Further warnings cover a missing email, emailVerified === false, a missing profile URL, and an ignored picture.

Jobs and schools are optional and manual. The dialog textarea says the paste stays in the browser. Merge paste runs parseLinkedInPaste and, if it returns experience or saw an Experience heading, replaces fields.experience (same idea for education). The source becomes "linkedin_oidc_plus_paste". An empty paste does not clear those arrays; the parser warns and leaves them unset so toFormValues keeps the current form rows.

The parser is a pile of heuristics in src/lib/linkedin/paste.ts, on purpose, with no network and no model:

  • It splits on headings: Experience / Work experience / Professional experience / Employment, and Education / Academic / Studies. About, summary, skills, licenses, projects, volunteer, and similar headings are ignored.
  • With no headings, it classifies blocks by a degree keyword versus a date line, and warns that the section may be wrong.
  • A job needs a date line. parseDateRange matches things like Jan 2020 - Present (month names, to or a dash, years 19xx/20xx, Present/Current/Now).
  • Blocks are blank-line separated, capped at 30 entries. Company vs title uses a middle-dot plus an employment type (Full-time, Contract, …) when LinkedIn's copy has one, otherwise "first line title, second line company".
  • Every successful parse pushes: "Experience and education were guessed from pasted text. LinkedIn's copy format varies, so review every field before confirming."

That last sentence is why paste is not the product. In testing, LinkedIn's copy layout was not stable enough to treat this as a reliable import. The UI still has it, because OIDC cannot fill Experience and Education at all, and the warnings are meant to force a review. It is enrichment, not the main path. We are not turning the flag on while the useful part of a CV depends on it.

toFormValues merges the draft onto the current editor values. Name and contact overlay. Experience and education are replaced only when the draft actually has those keys. Personal projects are left alone unless the draft has personal_projects, which neither OIDC nor the paste path sets.

The form reset fix

react-hook-form treats values as a controlled prop. reset() then loses to the next render. Import apply was a no-op until Form stopped passing values and passed defaultValues instead:

// src/components/forms/Form.tsx
const methods = useForm<EditorFormValues>({
  // defaultValues, not controlled `values`, so reset() from import sticks.
  defaultValues: createEmptyFormValues(),
});

const { watch, reset, getValues } = methods;

const applyLinkedInImport = (draft: ImportDraft) => {
  reset(toFormValues(draft, getValues()));
};

LinkedInImport gets onApply={applyLinkedInImport} and onRestore={(values) => reset(values)}. Restore runs from the snapshot on the way back, before the dialog. Apply runs only from Add to editor. Both go through reset, which is why the defaultValues comment matters. watch on personal details, contact, experience, and education is what drives the preview and the PDF dialog; after reset, those update from form state, still in the browser.

What is still incomplete

  • The flag is off in production. Import is not a live feature. The broker stays so a later LinkedIn login does not start from zero.
  • OIDC cannot fill a CV. Paste is a best-effort client parser with explicit "review every field" warnings, and it was unreliable against real LinkedIn copy.
  • The handoff is one cookie clear, not a durable idempotency key. Lose the cookie, wait 5 minutes, or redeem twice and the client says to import again.
  • There is no server CV store, by design. There is also no local workspace yet. Close the tab and the form is gone, aside from the snapshot that only exists during the OAuth round-trip.
  • Phase 2 (localStorage workspace / variants), Phase 3 (job-description rewrite), and Phase 4 (ATS checks) are parked. They are not started in this code.
  • src/app/api/resume still returns an empty payload. Do not read that as persistence.

Until the flag is the string true and the four server vars are set, the only honest way to describe this ship is: the plumbing is on main, the button is not on the site, and a CV still does not go through our server.